← Fundamentos de Sistemas Operativos Bloque 7 · Visión general de los servicios · Tema 26 de 34

Tema 26

Procesos: visión general

La palabra «proceso» apareció en casi todos los temas del curso. Llegó el momento de reunir lo que fuimos viendo por partes: de qué está hecho un proceso, cómo nace y cómo termina, por qué estados pasa y cómo se lee lo que ps y top dicen de él. Es el primero de los servicios del sistema operativo que recorremos en este bloque.

1. La unidad de todo lo demás

Este bloque recorre, uno por tema, los servicios que un sistema operativo ofrece: procesos, memoria, archivos, dispositivos y protección. De cada uno damos una vista de conjunto; los cursos siguientes de la ruta los estudian a fondo. Empezamos por los procesos porque los otros cuatro se definen en relación con ellos.

Un proceso es un programa en ejecución, junto con todos los recursos que el sistema operativo le asignó: su memoria, sus archivos abiertos, su identidad y su turno de procesador.

La primera mitad de la definición la establecimos en el tema 9: el programa es un archivo, pasivo; el proceso es ese programa cargado y avanzando. La segunda mitad es la que importa ahora. Para el kernel, el proceso es la unidad de asignación: no reparte la CPU entre programas, sino entre procesos; no concede memoria a un usuario, sino a un proceso; no comprueba los permisos de una persona, sino los del proceso que hace el pedido.

Es también la unidad de aislamiento. La ilusión de la máquina propia que describimos en el tema 2 —un procesador y una memoria para cada programa— es exactamente lo que el kernel le construye a cada proceso. Y por eso un error en uno termina con ese proceso y no con los demás.

2. De qué está hecho un proceso

A lo largo del curso fuimos encontrando las piezas por separado. Reunidas, son estas:

ComponenteQué esLo vimos en
Espacio de direccionesSu memoria: código, datos, heap, bibliotecas y pila.Tema 9; lo ampliamos en el 27
Contexto de CPUEl contenido de los registros, que se guarda cuando pierde el procesador.Temas 6 y 19
Pila de kernelLa pila que usa el kernel cuando trabaja en nombre de este proceso.Tema 12
Archivos abiertosLa tabla de sus descriptores: el 0, el 1, el 2 y los que haya abierto.Tema 14; lo ampliamos en el 28
IdentidadEl usuario y el grupo en cuyo nombre actúa.Tema 12; lo ampliamos en el 30
IdentificadoresSu PID y el de su padre.Tema 14
Estado y contabilidadSi está en ejecución, dormido o detenido; cuánta CPU consumió.Tema 19
EntornoEl directorio actual, las variables de entorno, los argumentos con que se lo lanzó.—

Todo eso cabe en la estructura task_struct cuyo extracto leímos en el tema 11, y en las que ella enlaza. Allí dijimos que, para el kernel, un proceso es esa estructura. La tabla anterior es su índice.

Hay una división útil entre las filas. El espacio de direcciones y los archivos abiertos son recursos: cosas que el proceso posee. El contexto de CPU y la pila son ejecución: el punto por donde va. La distinción reaparecerá en la sección 12, porque un proceso puede tener varios flujos de ejecución sobre los mismos recursos.

3. Identidad y parentesco

Cada proceso tiene un número que lo identifica mientras existe, el PID. El kernel los asigna en orden creciente; cuando llega al máximo, vuelve a empezar por los que quedaron libres.

terminal
$ echo $$                           # el PID de esta shell
4102
$ cat /proc/sys/kernel/pid_max      # el mayor PID posible
4194304
$ ps -o pid,ppid,user,comm
    PID    PPID USER     COMMAND
   4102    3310 diego    bash
   8104    4102 diego    ps

Un PID identifica a un proceso solo durante su vida. Una vez terminado, el número queda disponible y con el tiempo se asigna a otro, sin relación con el anterior. Por eso un PID anotado ayer no sirve hoy.

La columna PPID es el identificador del padre: el proceso que creó a este. Todo proceso tiene exactamente uno, y de ahí resulta el árbol que recorrimos en el tema 22, con el proceso 1 en la raíz. La salida lo muestra en pequeño: ps es hijo de la shell, que a su vez es hija de la terminal.

El parentesco no es decorativo. El padre es quien recibe la noticia cuando un hijo termina, y el hijo hereda del padre casi todo lo que es al nacer: el usuario, el directorio actual, las variables de entorno y los archivos abiertos. Por eso un programa lanzado desde una terminal escribe en esa terminal sin haber abierto nada: sus descriptores 0, 1 y 2 son los de la shell.

4. Cómo nace: fork y exec

En el tema 16 vimos con strace que la shell ejecuta un comando mediante tres llamadas: crear, reemplazar, esperar. Ahora las usamos nosotros. En Unix, crear un proceso y cargar un programa son operaciones separadas:

FunciónQué haceDespués de la llamada
fork()Crea un proceso nuevo, copia casi exacta del que llama.Hay dos procesos ejecutando el mismo programa en el mismo punto.
exec…()Reemplaza el programa del proceso actual por otro.Sigue habiendo un solo proceso, con el mismo PID y otro contenido.
wait(), waitpid()Espera a que un hijo termine, y obtiene cómo terminó.El padre continúa, con el resultado del hijo.

fork es la función más extraña que tiene C: se la llama una vez y retorna dos, una en cada proceso. Los dos continúan en la línea siguiente, con las mismas variables y los mismos valores. Lo único que los distingue es lo que fork les devuelve: al hijo, cero; al padre, el PID del hijo.

hijo.c
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    printf("Antes de fork: soy el proceso %d\n", (int)getpid());

    pid_t pid = fork();                 /* a partir de aquí hay dos procesos */

    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {                     /* esto lo ejecuta solo el hijo */
        printf("Hijo:  mi PID es %d y mi padre es %d\n", (int)getpid(), (int)getppid());
        return 7;
    }

    int estado;                         /* y esto, solo el padre */
    waitpid(pid, &estado, 0);
    printf("Padre: mi PID es %d; mi hijo %d termino con codigo %d\n",
           (int)getpid(), (int)pid, WEXITSTATUS(estado));
    return 0;
}
terminal
$ gcc -Wall -Wextra -g hijo.c -o hijo
$ ./hijo
Antes de fork: soy el proceso 8120
Hijo:  mi PID es 8121 y mi padre es 8120
Padre: mi PID es 8120; mi hijo 8121 termino con codigo 7

Un solo programa, y dos procesos que toman ramas distintas del mismo if. El hijo devuelve 7 desde main; el kernel guarda ese valor, y el padre lo recupera con waitpid. Es el código de salida, el número que la shell deja en $?.

Copiar un proceso entero parece un derroche, sobre todo si el hijo va a reemplazar su programa enseguida. No lo es, por el mecanismo que anticipamos en el tema 18: el kernel no copia la memoria, sino que hace que padre e hijo compartan las mismas páginas, marcadas como de solo lectura. Solo cuando uno de los dos escribe en una página se produce un fallo de página, y el kernel duplica esa página y ninguna otra. La técnica se llama copia al escribir.

Un búfer que se duplica

Prueba ./hijo | cat. La línea «Antes de fork» aparece dos veces, aunque el printf se ejecutó una sola. Es el tema 15 combinado con este. Con la salida en una tubería, el texto queda en el búfer de stdio, que es memoria del proceso. fork copia la memoria, búfer incluido, y cada proceso vacía su copia al terminar. La regla práctica es llamar a fflush(stdout) antes de fork.

5. Una shell en treinta líneas

Un hijo que ejecuta el mismo programa que el padre es útil en algunos casos. Lo habitual es que cargue otro, con alguna función de la familia exec. El siguiente programa recibe como argumentos un comando, lo ejecuta en un proceso hijo e informa cómo terminó. Es, reducido a su esqueleto, lo que hace una shell con cada línea que escribimos.

lanzar.c
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "uso: %s programa [argumentos]\n", argv[0]);
        return 1;
    }

    pid_t pid = fork();                 /* 1. crear */
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        execvp(argv[1], &argv[1]);      /* 2. reemplazar: el hijo pasa a ser otro programa */
        perror(argv[1]);                /* solo se llega aquí si execvp falló */
        return 127;
    }

    int estado;
    waitpid(pid, &estado, 0);           /* 3. esperar */

    if (WIFEXITED(estado))
        printf("[%d termino con codigo %d]\n", (int)pid, WEXITSTATUS(estado));
    else if (WIFSIGNALED(estado))
        printf("[%d fue finalizado por la senal %d]\n", (int)pid, WTERMSIG(estado));
    return 0;
}
terminal
$ gcc -Wall -Wextra -g lanzar.c -o lanzar
$ ./lanzar echo hola
hola
[8140 termino con codigo 0]
$ ./lanzar ls /noexiste
ls: cannot access '/noexiste': No such file or directory
[8143 termino con codigo 2]
$ ./lanzar ./segfault                # el programa del tema 18
Antes de escribir
[8147 fue finalizado por la senal 11]
$ ./lanzar noexiste
noexiste: No such file or directory
[8150 termino con codigo 127]
Línea de tiempo de fork, exec y wait: el padre llama a fork y queda bloqueado en wait; el hijo nace como copia del padre, reemplaza su programa con exec y termina con exit; entonces el padre continúa PADRE HIJO ejecuta bloqueado en wait continúa copia programa nuevo fork exec exit
El hijo nace como copia del padre, se transforma en otro programa y, al terminar, despierta al padre.

Las cuatro ejecuciones muestran las maneras en que puede terminar un proceso, y aclaran cosas que habíamos visto desde afuera:

Cómo terminaLo que recibe el padreLo que la shell deja en $?
Por su voluntad, con exit o volviendo de mainUn código entre 0 y 255. Por convención, 0 es éxito.Ese código
Finalizado por una señalEl número de la señal128 más ese número
El programa no pudo ejecutarseEl código que eligió el hijo tras fallar exec: por convención, 127127

La tercera ejecución explica de dónde salían, en el tema 18, el mensaje Segmentation fault y el 139. El kernel no escribe ese mensaje, y el programa caído tampoco. Lo escribe la shell, que al volver de waitpid descubre que su hijo no terminó por su cuenta sino por la señal 11, y lo informa. Nuestro programa hizo lo mismo con otras palabras.

Separar la creación del reemplazo tiene una ventaja que justifica el diseño. Entre fork y exec, el hijo todavía ejecuta código del padre, pero ya es otro proceso: puede cambiar sus propios archivos abiertos, su directorio o su usuario sin afectar al padre, y el programa que cargue a continuación heredará esos cambios. Así implementa la shell las redirecciones, como veremos en el tema 28. En Windows, que crea el proceso y carga el programa en una sola operación, todas esas opciones deben pasarse como parámetros de la llamada; es la diferencia que señalamos en el tema 25.

6. Zombis y huérfanos

Cuando un proceso termina, el kernel libera su memoria y cierra sus archivos. No puede eliminar todavía su entrada en la tabla de procesos, porque allí está guardado el código de salida, y el padre aún no lo recogió. En ese intervalo, el proceso está muerto y su registro sigue existiendo. Se lo llama zombi.

Normalmente el intervalo dura microsegundos. Se alarga cuando el padre no llama a wait:

zombi.c
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    pid_t pid = fork();

    if (pid == 0)
        return 0;                       /* el hijo termina enseguida */

    printf("Hijo %d terminado. El padre, %d, no lo recoge.\n", (int)pid, (int)getpid());
    sleep(30);                          /* el padre sigue, sin llamar a wait */
    return 0;
}
terminal
$ gcc -Wall -Wextra -g zombi.c -o zombi
$ ./zombi &
Hijo 8161 terminado. El padre, 8160, no lo recoge.
$ ps -o pid,ppid,stat,comm -C zombi
    PID    PPID STAT COMMAND
   8160    4102 S    zombi
   8161    8160 Z    zombi <defunct>

El hijo figura con estado Z y la marca <defunct>. No consume memoria ni procesador: es solo una entrada en una tabla. Por eso kill -9 no puede con él; no hay nada que finalizar. Desaparece cuando el padre lo recoge o cuando el padre termina. Un zombi suelto es inofensivo; miles de ellos indican un programa que crea hijos y no los espera, y pueden agotar los PID disponibles.

El caso opuesto es el del padre que termina primero. El hijo queda huérfano, y como todo proceso debe tener un padre, el kernel le asigna uno adoptivo:

huerfano.c
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    if (fork() == 0) {
        printf("Hijo: mi padre es %d\n", (int)getppid());
        sleep(3);                       /* para entonces, el padre ya terminó */
        printf("Hijo: ahora mi padre es %d\n", (int)getppid());
        return 0;
    }

    sleep(1);
    return 0;                           /* el padre termina sin esperar al hijo */
}
terminal
$ gcc -Wall -Wextra -g huerfano.c -o huerfano
$ ./huerfano
Hijo: mi padre es 8170
$ Hijo: ahora mi padre es 2011

El indicador de la shell reaparece al segundo, porque el proceso que ella esperaba terminó. Dos segundos después, el hijo, que sigue vivo, informa que cambió de padre. El adoptante tradicional es el proceso 1; en una sesión gráfica actual suele ser el systemd del usuario, el 2011 que vimos en el árbol del tema 22. En cualquiera de los dos casos, es un proceso que recoge a todos sus hijos cuando terminan, de modo que los huérfanos no quedan como zombis. Es la tercera particularidad del proceso 1 que dejamos pendiente entonces.

7. Los estados de un proceso

Un proceso no está siempre ejecutándose. En el tema 19 distinguimos al que tiene la CPU, al que espera su turno y al que duerme hasta que ocurra algo. El modelo completo tiene cinco estados:

Diagrama de estados de un proceso: de nuevo pasa a listo; de listo a en ejecución cuando el planificador lo elige; de en ejecución vuelve a listo si es desalojado, pasa a bloqueado si debe esperar, o a terminado; de bloqueado vuelve a listo cuando ocurre lo que esperaba Nuevo Listo En ejecución Terminado Bloqueado 1 2 3 4 5 6
Los números identifican las transiciones de la tabla siguiente.
TransiciónOcurre cuandoLa provoca
1. Nuevo → ListoEl kernel terminó de crearlo.Una llamada fork
2. Listo → En ejecuciónEl planificador le da un procesador.El kernel
3. En ejecución → ListoEl kernel le quita el procesador para dárselo a otro.Una interrupción, casi siempre la del temporizador
4. En ejecución → BloqueadoPide algo que no puede completarse de inmediato.Una llamada al sistema: read, sleep, wait
5. Bloqueado → ListoOcurre lo que esperaba.Una interrupción: llegó el dato, venció la pausa
6. En ejecución → TerminadoLlama a exit o recibe una señal que lo finaliza.El propio proceso, o el kernel

Las transiciones 3 y 4 son los dos tipos de cambio de contexto que contamos en el tema 19: la 3 es el involuntario, y la 4, el voluntario. Y hay dos ausencias que conviene notar. No existe una flecha de Bloqueado a En ejecución: un proceso que despierta no recupera el procesador, solo vuelve a la fila. Y no existe una de Listo a Bloqueado: para pedir algo hay que estar ejecutándose.

Linux muestra el estado de cada proceso con una letra, en la columna STAT de ps y en la columna S de top:

LetraNombreCorresponde a
RRunningEn ejecución o listo: Linux no los distingue con letras distintas.
SSleepingBloqueado, a la espera de un suceso; una señal puede despertarlo.
DUninterruptibleBloqueado en una operación que no admite interrupción, normalmente de disco.
TStoppedDetenido por una señal, hasta que otra lo reanude.
ZZombieTerminado, y todavía no recogido por su padre.
IIdleUn hilo del kernel sin trabajo.

En cualquier instante, casi todos los procesos de un sistema están en S. Es lo normal, y lo deseable: un proceso dormido no cuesta nada. Solo unos pocos compiten de verdad por los procesadores.

8. Leer ps

ps toma una fotografía de los procesos en un instante. Sin opciones muestra solo los de la terminal actual. La forma más usada para verlos todos es ps aux:

terminal (extracto)
$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0 168940 13312 ?        Ss   09:14   0:02 /sbin/init
root           2  0.0  0.0      0     0 ?        S    09:14   0:00 [kthreadd]
root         388  0.0  0.1  66820 18432 ?        S<s  09:14   0:01 /usr/lib/systemd/systemd-journald
root        1042  0.0  0.0   9508  2816 ?        Ss   09:14   0:00 /usr/sbin/cron -f -P
diego       4102  0.0  0.0  11420  5632 pts/0    Ss   10:31   0:00 bash
diego       8201  0.0  0.0  12980  4480 pts/0    R+   12:04   0:00 ps aux
ColumnaSignificado
USEREl usuario en cuyo nombre actúa el proceso.
PIDSu identificador.
%CPUEl tiempo de CPU que usó, como porcentaje del tiempo que lleva existiendo. Es un promedio de toda su vida, no un valor instantáneo.
%MEMSu memoria física, como porcentaje de la del equipo.
VSZEl tamaño de su espacio de direcciones, en KB: lo que tiene prometido.
RSSLa memoria física que ocupa de verdad, en KB.
TTYLa terminal a la que está asociado. Un ? indica que no tiene: es un servicio.
STATEl estado, con la letra de la sección anterior y marcas adicionales.
START, TIMECuándo se inició, y cuánto tiempo de CPU acumuló.
COMMANDEl comando con sus argumentos. Entre corchetes, los hilos del kernel.

La diferencia entre VSZ y RSS es la del tema 18: la memoria se promete al pedirla y se entrega al usarla. El proceso 1 tiene 168 MB de direcciones reservadas y ocupa 13. El hilo del kernel muestra ceros porque no tiene espacio de direcciones propio. Lo vemos en el tema 27.

Las marcas que acompañan a la letra de estado dan información adicional: s indica el primer proceso de una sesión; +, que está en primer plano en su terminal; < y N, prioridad más alta o más baja que la normal; l, que tiene varios hilos.

La última línea tiene una gracia: el único proceso en estado R es el propio ps. Para tomar la fotografía tiene que estar ejecutándose, y en un equipo tranquilo es el único que lo está.

Unas pocas variantes cubren casi todas las necesidades:

ComandoMuestra
ps -efTodos los procesos, con el PPID.
ps -ef --forestLo mismo, dibujando el árbol de parentesco.
ps -eo pid,ppid,stat,commSolo las columnas elegidas.
ps aux --sort=-%mem | headLos que más memoria ocupan.
ps -C nombreLos procesos de un programa determinado.
pgrep nombreSolo sus PID, para usar en otro comando.

9. Leer top

top muestra lo mismo que ps, actualizado cada tres segundos y ordenado por uso de CPU, con un resumen del sistema en la parte superior:

terminal (extracto)
$ top
top - 12:06:41 up  2:52,  2 users,  load average: 0.42, 0.31, 0.27
Tasks: 287 total,   1 running, 285 sleeping,   0 stopped,   1 zombie
%Cpu(s):  3.1 us,  1.2 sy,  0.0 ni, 95.4 id,  0.2 wa,  0.0 hi,  0.1 si,  0.0 st
MiB Mem :  15871.5 total,   7064.6 free,   4301.2 used,   4912.3 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.  11570.3 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
   2204 diego     20   0 5120340 412300 142112 S   2.3   2.5   4:12.08 gnome-shell
   3310 diego     20   0  812440  61200  44820 S   0.7   0.4   0:21.55 gnome-terminal-
   8240 diego     20   0   14112   5760   3584 R   0.3   0.0   0:00.04 top

Las cinco líneas del encabezado resumen los temas del curso. La tercera la explicamos en el tema 12, y las de memoria son asunto del tema 27. Quedan las dos primeras:

DatoSignificado
up 2:52El tiempo transcurrido desde el arranque.
load averageLa carga media: la cantidad promedio de procesos que querían ejecutarse, en el último minuto, en los últimos cinco y en los últimos quince.
Tasks: 287 totalCuántos procesos existen.
1 runningCuántos están en estado R.
285 sleepingCuántos están dormidos. Casi todos.
1 zombieCuántos esperan ser recogidos por su padre.

La carga media es el indicador más citado y el peor entendido. Cuenta los procesos en estado R, estén ejecutándose o esperando turno, y en Linux también los que están en D. Por sí solo, el número no dice nada: hay que compararlo con la cantidad de procesadores, que informa nproc.

Carga, en un equipo de 8 procesadoresInterpretación
0,4El equipo está casi ocioso.
8Todos los procesadores ocupados, y nadie espera.
20En promedio, doce procesos esperan turno o esperan al disco. El equipo está saturado.

Los tres valores juntos dan la tendencia: si el del último minuto supera al de los quince, la carga está subiendo.

En la tabla de procesos, las columnas VIRT y RES son las VSZ y RSS de ps; NI es el valor de cortesía del tema 23, y PR, la prioridad que el kernel calcula a partir de él. La diferencia importante está en %CPU: en top es el uso durante el último intervalo, no el promedio de toda la vida del proceso. Un valor de 100 equivale a un procesador completo, de modo que un programa con varios hilos puede mostrar más.

top es interactivo. Estas son las teclas más útiles:

TeclaEfecto
P, MOrdenar por uso de CPU o de memoria.
1Mostrar una línea por procesador en lugar del promedio.
HMostrar los hilos por separado.
kEnviar una señal a un proceso.
qSalir.

10. Señales: hablarle a un proceso

Las señales aparecieron ya en tres contextos: las que el kernel envía tras una excepción, en el tema 18; la que avisa al padre de que un hijo terminó, en el tema 16; y las que usa systemd para detener un servicio, en el tema 22. Son un mismo mecanismo: un aviso numerado que el kernel entrega a un proceso, y que este puede atender con una función propia, ignorar, o dejar que tenga su efecto por defecto.

Además del kernel, puede enviarlas un usuario a sus propios procesos, con el comando kill. El nombre engaña: sirve para enviar cualquier señal, no solo las que finalizan.

SeñalNúmeroCómo se envíaEfecto por defecto¿Puede atenderla el programa?
SIGINT2Ctrl+CTerminarSí
SIGTERM15kill PIDTerminarSí
SIGKILL9kill -9 PIDTerminarNo
SIGTSTP20Ctrl+ZDetenerSí
SIGSTOP19kill -STOP PIDDetenerNo
SIGCONT18kill -CONT PIDReanudarSí
SIGHUP1Al cerrarse la terminalTerminarSí

La última columna distingue dos clases. SIGTERM es un pedido: el programa puede atenderla para guardar su trabajo y cerrar ordenadamente. SIGKILL no llega nunca al programa: la ejecuta el kernel, que elimina el proceso sin darle oportunidad de nada. De ahí la regla de buena práctica:

terminal
$ sleep 300 &
[1] 8230
$ kill 8230                         # primero pedirlo: SIGTERM
[1]+  Terminated              sleep 300

$ sleep 300 &
[1] 8233
$ kill -9 8233                      # solo si no responde: SIGKILL
[1]+  Killed                  sleep 300

Es la secuencia que sigue systemd al detener un servicio o apagar el equipo: pedir, esperar un plazo, y forzar. Usar kill -9 como primer recurso es una mala costumbre; un programa finalizado así puede dejar archivos a medio escribir. Los mensajes Terminated y Killed los escribe la shell, por el mismo camino del tema 18: es el padre, y se entera por waitpid.

Un usuario solo puede enviar señales a sus propios procesos; el administrador, a cualquiera. Para indicar el destinatario por nombre en lugar de por PID están pkill y killall. Cómo se escribe un manejador y qué se puede hacer dentro lo vimos en esbozo en el tema 18, y se estudia en el curso de procesos.

11. Primer y segundo plano

Una shell ejecuta normalmente un comando y espera a que termine: está bloqueada en wait, como en el diagrama de la sección 5. Ese comando está en primer plano, y es el que recibe lo que se escribe en el teclado. Añadir & al final cambia una sola cosa: la shell no espera. El comando queda en segundo plano y la shell vuelve a mostrar su indicador. Lo hicimos varias veces en el curso, con bucle y con zombi.

terminal
$ sleep 300                         # en primer plano: la shell espera
^Z                                  # Ctrl+Z: el kernel le envía SIGTSTP
[1]+  Stopped                 sleep 300
$ ps -o pid,stat,comm -C sleep
    PID STAT COMMAND
   8251 T    sleep
$ bg                                # reanudarlo en segundo plano: SIGCONT
[1]+ sleep 300 &
$ jobs
[1]+  Running                 sleep 300 &
$ fg                                # traerlo al primer plano
sleep 300
^C                                  # Ctrl+C: SIGINT

La secuencia recorre tres estados del diagrama y tres señales de la tabla. Ctrl+Z no es una función de la shell: es el kernel, que al recibir esa combinación de teclas en la terminal envía SIGTSTP al proceso que está en primer plano, y lo deja en estado T. bg y fg le envían SIGCONT. Y Ctrl+C es SIGINT.

Comando o teclaEfecto
comando &Lanzarlo en segundo plano.
Ctrl+ZDetener el que está en primer plano.
jobsListar los trabajos de esta shell.
bg, fgReanudar un trabajo en segundo o en primer plano.
kill %1Enviar una señal al trabajo número 1.

Los trabajos pertenecen a la shell que los lanzó. Si se cierra la terminal, el kernel les envía SIGHUP y terminan. Para que un programa siga corriendo después hay que lanzarlo con nohup o, mejor, convertirlo en un servicio, como hicimos en el tema 22.

12. Procesos e hilos

En la sección 2 separamos los recursos de un proceso de su ejecución. Esa separación tiene consecuencias. Nada obliga a que haya un solo flujo de ejecución por conjunto de recursos.

Un hilo (thread) es un flujo de ejecución dentro de un proceso. Tiene su propio contador de programa, sus registros y su pila, y comparte con los demás hilos del proceso la memoria, los archivos abiertos y la identidad.

Dos procesosDos hilos de un proceso
MemoriaSeparada: cada uno la suyaCompartida: ven las mismas variables
Archivos abiertosCada uno su tablaLa misma
Registros y pilaPropiosPropios
Comunicarse entre síRequiere un mecanismo del kernelBasta con usar la memoria común
Un error en unoNo afecta al otroPuede corromper al otro, y una señal fatal termina con todos
Cambiar de uno a otroMás costoso: cambia el espacio de direccionesMás barato

Los hilos permiten que un programa aproveche varios núcleos a la vez, o que atienda la interfaz mientras hace un cálculo largo. El precio es el que anunciamos al final del tema 19: al compartir memoria, dos hilos pueden modificar el mismo dato a la vez, y aparecen los errores de concurrencia.

En Linux, la distinción es más tenue de lo que sugiere la tabla. El kernel planifica tareas, y tanto un proceso como un hilo son tareas creadas con la misma llamada, clone, que recibe una lista de lo que la nueva tarea debe compartir con la que la crea. Si no comparte nada, es un proceso; si comparte memoria y archivos, es un hilo. Es la llamada que apareció en la traza del tema 16, y por eso cada hilo tiene su propio identificador, el que devolvía gettid en el tema 14.

terminal
$ ps -o pid,nlwp,comm -p 2204,4102
    PID NLWP COMMAND
   2204   14 gnome-shell
   4102    1 bash

La columna NLWP es la cantidad de hilos. El entorno gráfico tiene catorce; la shell, uno. ps -eLf los lista por separado. Los hilos y su sincronización son el tema central del curso de concurrencia.

13. Un directorio por proceso

Todo lo que ps y top muestran lo obtienen del mismo lugar. Por cada proceso existente, el kernel presenta en /proc un directorio cuyo nombre es el PID:

terminal
$ ls /proc | grep -c '^[0-9]'       # un directorio por proceso
287
$ ls -l /proc/$$/exe /proc/$$/cwd
lrwxrwxrwx 1 diego diego 0 oct 12 12:20 /proc/4102/cwd -> /home/diego/so
lrwxrwxrwx 1 diego diego 0 oct 12 12:20 /proc/4102/exe -> /usr/bin/bash
$ tr '\0' ' ' < /proc/1042/cmdline; echo
/usr/sbin/cron -f -P
ArchivoContenidoComponente de la sección 2
statusNombre, estado, PID, padre, usuario, memoria, cambios de contexto.Identificadores, estado, contabilidad
cmdlineLos argumentos con que se lo lanzó, separados por un byte cero.Entorno
exeUn enlace al archivo ejecutable.—
cwdUn enlace a su directorio actual.Entorno
environSus variables de entorno.Entorno
mapsEl mapa de su memoria.Espacio de direcciones
fd/Un enlace por cada descriptor abierto.Archivos abiertos
task/Un subdirectorio por cada hilo.Ejecución

Varios de estos archivos ya los usamos: status en los temas 11 y 19, maps en el 9, fd en un ejercicio del 25. La tercera columna muestra que el directorio es la tabla de la sección 2 hecha visible: cada componente de un proceso tiene su archivo. El conteo coincide con el total de top, y no por casualidad: top obtiene su número recorriendo este directorio.

Recorremos /proc entero en el tema 31. Antes, el tema 27 se ocupa del componente más voluminoso de un proceso: su memoria.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿En qué sentido el proceso es la «unidad de asignación» del sistema operativo?

El kernel reparte la CPU, concede la memoria, abre los archivos y comprueba los permisos por proceso. Todo recurso pertenece a un proceso, y todo pedido lo hace un proceso.

2. Menciona cinco cosas que forman parte de un proceso además de su código.

Su espacio de direcciones, con datos, heap y pila; el contenido de sus registros; sus archivos abiertos; el usuario en cuyo nombre actúa; y su PID y el de su padre. También su estado, su directorio actual y sus variables de entorno.

3. ¿Qué hereda un proceso de su padre?

El usuario, el directorio actual, las variables de entorno y los archivos abiertos, entre otras cosas. Por eso un programa lanzado desde una terminal puede escribir en ella sin abrirla.

4. ¿Qué devuelve fork, y a quién?

Retorna dos veces. Al proceso hijo le devuelve 0. Al padre le devuelve el PID del hijo, o −1 si no se pudo crear.

5. Después de un exec exitoso, ¿cambia el PID del proceso?

No. exec reemplaza el programa dentro del mismo proceso: cambian el código y la memoria, y se conservan el PID, el padre y los archivos abiertos.

6. En lanzar.c, ¿por qué hay un perror después de execvp, si exec reemplaza el programa?

Porque esa línea solo se alcanza si execvp falló, por ejemplo porque el programa no existe. Si tiene éxito, el código que sigue ya no está: fue reemplazado.

7. ¿Quién escribe «Segmentation fault» cuando un programa falla, y cómo se entera?

La shell. Es el proceso padre, y al volver de waitpid comprueba que su hijo no terminó por su cuenta, sino por la señal 11. Escribe el mensaje y deja 139 en $?.

8. ¿Por qué no se puede eliminar un zombi con kill -9?

Porque ya está terminado: no tiene memoria ni ejecución, solo una entrada en la tabla de procesos con su código de salida. Desaparece cuando su padre lo recoge con wait, o cuando el padre termina.

9. ¿Qué ocurre con un proceso cuyo padre termina antes que él?

Queda huérfano, y el kernel le asigna como padre al proceso 1 o al gestor de la sesión del usuario, que lo recogerá cuando termine.

10. Un proceso bloqueado en un read recibe por fin sus datos. ¿A qué estado pasa?

A listo, no a en ejecución. Queda en condiciones de ejecutarse, y recibe el procesador cuando el planificador lo elige.

11. En ps aux, un proceso muestra VSZ de 2 GB y RSS de 30 MB. ¿Cuánta memoria ocupa?

Treinta megabytes de memoria física. Los 2 GB son el tamaño de su espacio de direcciones: lo que tiene reservado, la mayor parte del cual nunca usó.

12. Una carga media de 6, ¿es alta?

Depende de la cantidad de procesadores. En un equipo de 16 es moderada; en uno de 2 significa que, en promedio, cuatro procesos esperan turno.

13. ¿Qué diferencia hay entre SIGTERM y SIGKILL?

SIGTERM se entrega al programa, que puede atenderla para cerrar ordenadamente. SIGKILL no llega al programa: el kernel elimina el proceso de inmediato. Por eso conviene enviar primero la primera.

14. ¿Qué comparten dos hilos de un mismo proceso, y qué tiene cada uno por separado?

Comparten la memoria, los archivos abiertos y la identidad. Cada uno tiene su contador de programa, sus registros y su pila.

Para practicar

  1. Ejecuta ./hijo y luego ./hijo | cat. Explica la diferencia y corrígela con una llamada a fflush.
  2. Modifica hijo.c para que el proceso cree tres hijos, y que cada uno termine con un código distinto. Haz que el padre los espere a todos e informe cada código.
  3. Usa lanzar con ./division, ./ilegal y ./pausa, del tema 18. ¿Qué número de señal informa en cada caso? Compáralo con la tabla de ese tema.
  4. Ejecuta strace -f -e trace=clone,execve,wait4,exit_group ./lanzar echo hola y relaciona cada línea con una de lanzar.c.
  5. Lanza ./zombi & y busca al zombi en top: ¿qué línea del encabezado cambia? Intenta eliminarlo con kill -9. Después finaliza al padre y comprueba qué pasa con el hijo.
  6. Ejecuta ps -eo stat | cut -c1 | sort | uniq -c. ¿Cuántos procesos hay en cada estado? ¿Cuál es el que está en R?
  7. Lanza tantas copias de bucle, del tema 19, como procesadores tengas más dos. Observa durante un minuto la carga media en top y el valor al que tiende.
  8. Con sleep 300 en primer plano, reproduce la sesión de la sección 11. Luego envíale SIGSTOP y SIGCONT con kill desde otra terminal, mirando su estado con ps.
  9. Elige un proceso de tu sesión y, usando solo archivos de /proc/PID, averigua su ejecutable, su directorio actual, su padre y cuántos hilos tiene.
  10. Busca con ps -eo pid,nlwp,comm --sort=-nlwp | head los programas con más hilos de tu sistema.