
En este artículo, me gustaría hablar sobre la ruta de la vida de los procesos en la familia del sistema operativo Linux. En teoría y ejemplos, veré cómo nacen y mueren los procesos, una pequeña charla sobre la mecánica de las llamadas y señales del sistema.
Este artículo está dirigido a principiantes en programación de sistemas y aquellos que solo quieren aprender un poco más sobre cómo funcionan los procesos en Linux.
Todo lo escrito a continuación se aplica a Debian Linux con el kernel 4.15.0.
Contenido
- Introduccion
- Atributos de proceso
- Ciclo de vida del proceso
- Proceso de nacimiento
- Estado listo
- El estado es "en ejecución"
- Reencarnación en otro programa
- Estado pendiente
- Estado de parada
- Proceso de finalización
- El estado de los "zombies"
- Olvido
- Agradecimientos
Introduccion
El software del sistema interactúa con el núcleo del sistema a través de funciones especiales: llamadas al sistema. En casos raros, hay una API alternativa, por ejemplo, procfs o sysfs, hecha en forma de sistemas de archivos virtuales.
Atributos de proceso
El proceso en el núcleo se presenta simplemente como una estructura con muchos campos (la definición de la estructura se puede leer
aquí ).
Pero dado que el artículo está dedicado a la programación del sistema, y no al desarrollo del núcleo, estamos algo abstraídos y simplemente nos centramos en los campos de proceso importantes para nosotros:
- Id. De proceso (pid)
- Descriptores de archivos abiertos (fd)
- Manejadores de señal
- Directorio de trabajo actual (cwd)
- Variables de entorno (entorno)
- Código de retorno
Ciclo de vida del proceso

Proceso de nacimiento
Solo un proceso en el sistema nace de una manera especial -
init
- es generado directamente por el núcleo. Todos los demás procesos aparecen duplicando el proceso actual utilizando la llamada al sistema
fork(2)
. Después de ejecutar
fork(2)
, obtenemos dos procesos casi idénticos, con la excepción de los siguientes puntos:
fork(2)
devuelve el PID del niño al padre, 0 se devuelve al niño;- El hijo cambia el PPID (Id. De proceso principal) al PID del padre.
Después de ejecutar
fork(2)
, todos los recursos del proceso hijo son una copia de los recursos del padre. Copiar un proceso con todas las páginas de memoria asignadas es costoso, por lo que el kernel de Linux utiliza la tecnología Copy-On-Write.
Todas las páginas en la memoria del padre se marcan como de solo lectura y están disponibles tanto para el padre como para el niño. Tan pronto como uno de los procesos cambia los datos en una página en particular, esta página no cambia, pero la copia ya está copiada y modificada. En este caso, el original está "desatado" de este proceso. Tan pronto como el original de solo lectura permanezca "vinculado" a un solo proceso, la página se reasigna al estado de lectura-escritura.
Un ejemplo de un programa inútil simple con fork (2) #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; default: // Parent printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; } return 0; }
$ gcc test.c && ./a.out my pid = 15594, returned pid = 15595 my pid = 15595, returned pid = 0
Estado listo
Inmediatamente después de la ejecución, la
fork(2)
entra en el estado listo.
De hecho, el proceso hace cola y espera a que el planificador en el núcleo permita que el proceso se ejecute en el procesador.
El estado es "en ejecución"
Tan pronto como el planificador puso el proceso en ejecución, comenzó el estado "en ejecución". El proceso puede ejecutar todo el período de tiempo propuesto (cuántico) o puede dar paso a otros procesos que utilizan la exportación del sistema
sched_yield
.
Reencarnación en otro programa
Algunos programas implementan lógica en la cual el proceso padre crea un hijo para resolver un problema. El niño en este caso resuelve un problema específico, y el padre solo delega tareas a sus hijos. Por ejemplo, un servidor web con una conexión entrante crea un hijo y le pasa el procesamiento de la conexión.
Sin embargo, si necesita ejecutar otro programa, debe recurrir a la llamada al sistema
execve(2)
:
int execve(const char *filename, char *const argv[], char *const envp[]);
o la biblioteca llama a
execl(3), execlp(3), execle(3), execv(3), execvp(3), execvpe(3)
:
int execl(const char *path, const char *arg, ... ); int execlp(const char *file, const char *arg, ... ); int execle(const char *path, const char *arg, ... ); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execvpe(const char *file, char *const argv[], char *const envp[]);
Todas las llamadas anteriores ejecutan el programa, cuya ruta se indica en el primer argumento. Si tiene éxito, el control se transfiere al programa cargado y no se devuelve al original. En este caso, el programa cargado tiene todos los campos de la estructura del proceso, excepto los descriptores de archivo marcados como
O_CLOEXEC
, se cerrarán.
¿Cómo no confundirse en todos estos desafíos y elegir el correcto? Es suficiente entender la lógica de denominación:
- Todas las llamadas comienzan con
exec
- La quinta letra define el tipo de argumento que pasa:
- l significa lista , todos los parámetros se pasan como
arg1, arg2, ..., NULL
- v significa vector , todos los parámetros se pasan en una matriz terminada en nulo;
- La letra p , que significa camino , puede seguir. Si el argumento del
file
comienza con un carácter distinto de "/", el file
especificado se busca en los directorios enumerados en la variable de entorno PATH - La última puede ser la letra e , que indica el entorno . En tales llamadas, el último argumento es una matriz terminada en nulo de cadenas terminadas en nulo de la
key=value
forma key=value
- variables de entorno que se pasarán al nuevo programa.
Ejemplo de llamada a / bin / cat --help via execve #define _GNU_SOURCE #include <unistd.h> int main() { char* args[] = { "/bin/cat", "--help", NULL }; execve("/bin/cat", args, environ); // Unreachable return 1; }
$ gcc test.c && ./a.out Usage: /bin/cat [OPTION]... [FILE]... Concatenate FILE(s) to standard output. * *
La familia de llamadas
exec*
permite ejecutar scripts con derechos de ejecución y comenzar con una secuencia de shebangs (#!).
Un ejemplo de ejecución de una secuencia de comandos con una RUTA falsificada utilizando el ejemplo #define _GNU_SOURCE #include <unistd.h> int main() { char* e[] = {"PATH=/habr:/rulez", NULL}; execle("/tmp/test.sh", "test.sh", NULL, e); // Unreachable return 1; }
$ cat test.sh
Hay una convención que implica que argv [0] coincide con los argumentos nulos para funciones en la familia exec *. Sin embargo, esto puede ser violado.
Ejemplo cuando el gato se convierte en perro usando execlp #define _GNU_SOURCE #include <unistd.h> int main() { execlp("cat", "dog", "--help", NULL); // Unreachable return 1; }
$ gcc test.c && ./a.out Usage: dog [OPTION]... [FILE]... * *
Un lector curioso puede notar que en la firma de la función
int main(int argc, char* argv[])
hay un número: el número de argumentos, pero no se pasa nada por el estilo a la familia de funciones
exec*
. Por qué Porque cuando se inicia el programa, el control no se transfiere inmediatamente a main. Antes de esto, se realizan algunas acciones definidas por glibc, incluido contar argc.
Estado pendiente
Algunas llamadas al sistema pueden tardar mucho tiempo, como las E / S. En tales casos, el proceso pasa a un estado "pendiente". Tan pronto como se complete la llamada al sistema, el kernel pondrá el proceso en el estado "listo".
En Linux, también hay un estado de "espera" en el que el proceso no responde a las señales de interrupción. En este estado, el proceso se vuelve "indestructible" y todas las señales entrantes permanecen en línea hasta que el proceso abandona este estado.
El núcleo mismo elige a qué estado transferir el proceso. Con mayor frecuencia, los procesos que solicitan E / S están en el estado "en espera (sin interrupciones)". Esto es especialmente notable cuando se usa un disco remoto (NFS) con un Internet no muy rápido.
Estado de parada
Puede pausar un proceso en cualquier momento enviándole una señal SIGSTOP. El proceso pasará a un estado "detenido" y permanecerá allí hasta que reciba una señal para continuar trabajando (SIGCONT) o morir (SIGKILL). Las señales restantes se pondrán en cola.
Proceso de finalización
Ningún programa puede cerrarse solo. Solo pueden pedir esto al sistema con la
_exit
sistema
_exit
o ser cancelado por el sistema debido a un error. Incluso cuando devuelve un número de
main()
,
_exit
todavía se llama implícitamente.
Aunque el argumento de la llamada al sistema es int, solo el byte bajo del número se toma como código de retorno.
El estado de los "zombies"
Inmediatamente después de que el proceso se haya completado (sin importar si es correcto o no), el kernel escribe información sobre cómo terminó el proceso y lo coloca en el estado "zombie". En otras palabras, un zombie es un proceso completo, pero su memoria todavía está almacenada en el núcleo.
Además, este es el segundo estado en el que el proceso puede ignorar con seguridad la señal SIGKILL, porque no puede morir de nuevo.
Olvido
El código de retorno y el motivo de la finalización del proceso todavía se almacenan en el kernel y deben tomarse desde allí. Para hacer esto, puede usar las llamadas apropiadas del sistema:
pid_t wait(int *wstatus); pid_t waitpid(pid_t pid, int *wstatus, int options);
Toda la información sobre la finalización del proceso se ajusta al tipo de datos int. Las macros descritas en la
waitpid(2)
del
waitpid(2)
man
waitpid(2)
se utilizan para obtener el código de retorno y el motivo de la finalización del programa.
Ejemplo de finalización correcta y recibo de un código de retorno #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child return 13; default: { // Parent int status; waitpid(pid, &status, 0); printf("exit normally? %s\n", (WIFEXITED(status) ? "true" : "false")); printf("child exitcode = %i\n", WEXITSTATUS(status)); break; } } return 0; }
$ gcc test.c && ./a.out exit normally? true child exitcode = 13
Ejemplo de finalización incorrectaAl pasar argv [0] como NULL, se produce un bloqueo.
#include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child execl("/bin/cat", NULL); return 13; default: { // Parent int status; waitpid(pid, &status, 0); if(WIFEXITED(status)) { printf("Exit normally with code %i\n", WEXITSTATUS(status)); } if(WIFSIGNALED(status)) { printf("killed with signal %i\n", WTERMSIG(status)); } break; } } return 0; }
$ gcc test.c && ./a.out killed with signal 6
Hay momentos en que un padre termina antes que el hijo. En tales casos,
init
se convertirá en el padre del niño y usará la llamada de
wait(2)
cuando llegue el momento.
Después de que el padre ha tomado información sobre la muerte del niño, el núcleo borra toda la información sobre el niño para que otro proceso pronto tome su lugar.
Agradecimientos
Gracias a Sasha "Al" por la edición y asistencia en el diseño;
Gracias a Sasha "Reisse" por las respuestas claras a preguntas difíciles.
Soportaron la inspiración que me atacó y el aluvión de mis preguntas que me atacaron.