1. Introduction

Shells are one of the main and most fundamental ways to control Linux. After an interactive login through a given terminal, users are presented with their preconfigured shell so they can further interact with and command the system.

In this tutorial, we’ll talk about shells and come up with a method to get the number of current running shell processes in a stable and reliable way, executing a command in each one. First, we summarize our knowledge of shells and how to check the available ones in a given Linux system. Next, we discuss how commands run in the shell. After that, we go over the login process hierarchy. Then, we explore shell spawning in general. Finally, we count the number of shells in a system and create a script to run a command in each one.

We tested the code on Debian 12 (Bookworm) with GNU Bash 5.2.15. It should work in most POSIX-compliant environments unless otherwise specified.

2. Shells

We already know that a shell is a process that receives commands, interprets each, and then makes the necessary calls so the commands get executed by the operating system (OS). Shells can also handle all input and output during this process.

Many shells exist, but only some are considered major:

  • sh: Bourne Shell, classic, rudimentary, POSIX-compliant, default for UNIX v7
  • [t]csh: C shell, fairly basic, C-like, with tcsh as the expansion of csh
  • bash: Bourne Again Shell, a considerable improvement over sh with complex features
  • dash: Debian Almquist Shell, POSIX-compliant, simpler than bash, mainly meant to replace sh as a more feature-rich default
  • zsh: Z Shell, feature-rich, default for Kali Linux
  • ksh: Korn Shell, POSIX-compliant, feature-rich, less documentation

However, we can usually get a full list of shells available to a given system from the /etc/shells file:

$ cat /etc/shells
# /etc/shells: valid login shells
/bin/sh
/bin/bash
/usr/bin/bash
/bin/rbash
/usr/bin/rbash
/bin/dash
/usr/bin/dash
/usr/bin/tmux
/bin/zsh
/usr/bin/zsh
/usr/bin/screen
/bin/ksh93
/usr/bin/ksh93
/bin/rksh93
/usr/bin/rksh93
/bin/tcsh
/usr/bin/tcsh

Here, we see different paths and alternative names for the shells we discussed, as well as additional shells, e.g., provided by the terminal multiplexers tmux and screen.

To get only the list of shell paths, we use grep to filter out any lines that don’t start with a / forward slash, i.e., an absolute path:

$ cat /etc/shells | grep '^/'
/bin/sh
/bin/bash
/usr/bin/bash
[...]
/usr/bin/tmux
[...]

Further, we can exclude terminal multiplexers since they also need an actual shell within them:

$ cat /etc/shells | grep '^/' | grep -v 'tmux\|screen'
/bin/sh
/bin/bash
/usr/bin/bash
[...]

Finally, if needed, there is a way to only get the unique list of shell executable filenames via basename and sort:

for shell in $(cat /etc/shells | grep '^/' | grep -v 'tmux\|screen'); do basename $shell; done | sort -u
bash
dash
ksh93
rbash
rksh93
sh
tcsh
zsh

Naturally, we can identify processes as shells by several criteria:

  • process (executable) filename
  • shell path
  • hash of file

While we won’t delve into the last part because hashes change with versions, hash verification is one of the most robust ways to ensure a given file is the one we expect. Importantly, verifying the hash also prevents the use of infected and maliciously modified files, but, as usual, may cause inconvenience after automatic updates.

3. Running Commands in the Shell

Importantly, commands can be input in shells in two main ways:

  • interactively by the user
  • non-interactively

Of course, the first way requires user input as the keyboard is usually stdin. Similarly, the user is often the intended output recipient since stdout is the terminal by default.

To use non-interactive command input, we can employ a shell-specific switch (commonly -c) and supply commands when starting the shell:

$ sh -c '<COMMANDS>'

Here, we spawn the sh shell and supply some COMMANDS to it in the form of a string argument.

Alternatively, we can use a more advanced method such as GNU Debugger (GDB) to attach to and inject a command directly within the shell process input stream:

$ gdb -p <SHELL_PID> -batch-silent -ex 'p system("<COMMANDS>")' 2>/dev/null

In this case, since we don’t run the command within the target shell and don’t spawn a new one, we need to supply the SHELL_PID shell process ID (PID) in addition to the COMMANDS.

4. Login Process Hierarchy

To begin with, let’s explore the terminal process hierarchy that forms after a basic interactive login:

xost login: baeldung
Password:

[...]

$ tty
/dev/pts/0
$ ps -H -t /dev/pts/0
PID  TTY        TIME  CMD
666  pts/0  00:00:01  bash
667  pts/0  00:00:00    ps
$ echo $SHELL
/bin/bash
$ echo $$
666

In this case, we log in as the baeldung user. After logging in, we perform several operations:

  1. tty confirms the current pseudo-terminal as PTY 0, i.e., pts/0
  2. ps selects processes from [-t]erminal pts/0 and outputs their [-H]ierarchy, resulting in only the bash process apart from ps itself
  3. $SHELL environment variable has a value of /bin/bash, meaning Bash is our current (and default) shell
  4. $$ special variable has a value of 666, which is the PID of the current shell

At this point, we understand that a single shell runs right after the login process. Thus, we can expect at least one session in progress for every logged-in terminal.

5. Running (on) Shells

Of course, we can spawn a shell at any time by either logging in at another terminal or simply invoking the shell process. Naturally, when using the command line interface (CLI), we do the latter within a currently running shell:

$ /bin/bash
$ ps -H -t /dev/pts/0
PID  TTY        TIME  CMD
666  pts/0  00:00:01  bash
668  pts/0  00:00:00    bash
[...]

After running another Bash process, we see it in the hierarchy. Now, we have two running shells within the same terminal. Although we directly interact with only one of them, both are active.

If we exit the new current shell, we get back to the other one:

$ exit
$ ps -H -t /dev/pts/0
PID  TTY        TIME  CMD
666  pts/0  00:00:01  bash
670  pts/0  00:00:00    ps

So, the shell count can’t be accurately deduced from the number of users or terminals.

6. Counting Shells

After getting to know shells and how they spawn, let’s create a simple script to find the current shell processes:

$ cat shellspid.sh
#!/usr/bin/env bash

# get current shells
shells=$(cat /etc/shells | grep '^/' | grep -v 'screen\|tmux')

# shell processes array
shellpids=()

# for each shell, add PID to shell processes
for shell in $shells; do
  shellpids+=($(pidof $shell))
done

# exclude script shell PID and leave only unique values
printf '%s\n' ${shellpids[@]} | grep -v $$ | sort -u

Here, we use /etc/shells to get the available shells in the system, excluding terminal multiplexers. Consequently, we store the filtered list of available shells in the shells variable via command substitution. After that, we define the shellpids array and populate it in a for loop over shells, where we use pidof to convert each shell path to its respective PID.

Finally, we only output the unique values from shellpids and exclude the current shell PID with grep.

Let’s make the script executable via chmod:

$ chmod +x shellspid.sh

Now, we can see two sample runs:

$ ./shellspid.sh
666
$ bash
$ ./shellspid.sh
666
668

The first run shows PID 666 as the only shell process, while the second also adds PID 668 for the new shell we spawned within the first.

Naturally, we can use wc to count the total number of shell processes, i.e., –lines:

$ ./shellspid.sh | wc --lines
2

Since the main part of our script is ready, we can add one more function to it.

7. Run Command in All Shells

Now, let’s copy and expand our script to run a command within each of the shells:

$ cat shellsexec.sh
#!/usr/bin/env bash

# get current shells
shells=$(cat /etc/shells | grep '^/' | grep -v 'screen\|tmux')

# shell processes array
shellpids=()

# for each shell, add PID to shell processes
for shell in $shells; do
  shellpids+=($(pidof $shell))
done

# exclude script shell PID and leave only unique values
shellpids=($(printf '%s\n' ${shellpids[@]} | grep -v $$ | sort -u))

# run the supplied parameters as a command for each shell
for shellpid in "${shellpids[@]}"; do
  gdb -p $shellpid -batch-silent -ex 'p system("'"$*"'")' 2>/dev/null
done

Next, we make the shellsexec.sh executable:

$ chmod +x shellsexec.sh

At this point, using the shellsexec.sh script, we can execute any command line we pass to it within all currently running shells:

$ ./shellsexec echo Test.
Test.

This can be helpful in different scenarios, such as running a screensaver after a timeout.

8. Summary

In this article, we talked about shells, how to see which ones are available, count the running ones, and execute commands within each.

In conclusion, the shell is the primary command execution environment for Linux systems and, as such, is a very powerful tool, control over which is critical.

Comments are closed on this article!