1. Qué hereda el hijo
Cuando fork crea un proceso hijo, este recibe una copia de la mayoria de los atributos del padre: Memoria, descriptores de archivo, variables de entorno, Directorio de trabajo, umask, senales, limites de recursos. Pero no todo se hereda de la misma forma: la memoria se copia con copy-on-write, los descriptores se comparten (apuntan a las mismás estructuras del núcleo), y algunos atributos no se heredan en absoluto.
Entender que se hereda y como es fundamental para escribir programás correctos con fork. Si no se entiende la herencia, se cometen errores como el del bufer de stdio (printf que aparece dos veces), o se comparten descriptores de archivo sin querer.
La herencia se puede resumir en una regla general: el hijo recibe una copia de todo lo que el padre tiene en el momento de fork, excepto los atributos que son únicos del proceso (PID, PPID, timers, bloqueos de archivo). Pero la "copia" puede ser real (Memoria) o compartida (descriptores).
2. Herencia de la memoria
El hijo recibe una copia del espacio de direcciónes del padre: Código, datos, BSS, heap y stack. Pero esta copia no es fica: se usa copy-on-write (COW), Que ya viste en el tema 1 y que profundizaremos en el tema 13.
Lo que ocurre es:
- fork crea una nueva tabla de páginas para el hijo, Que apunta a las mismás páginas ficas que el padre.
- Las páginas se marcan como sólo lectura en ambas tablas de páginas.
- Cuando uno de los dos modifica una página, el núcleo intercepta la escritura (porque la página es sólo lectura), copia la página fica, y actualiza la tabla de páginas del proceso que escribio para que apunte a la nueva copia.
- El otro proceso sigue viendo la página original, Sin los cambios.
Esto significa que las variables globales, estaticas y locales del padre existen en el hijo con los mismos valores iniciales, pero son independientes: Sí el hijo modifica una variable, el padre no ve el cambio, y viceversa.
#define _POSIX_C_SOURCE 200809L
#include
#include
int global = 42;
int main(void)
{
int local = 10;
int *heap = malloc(Sizeof *heap);
*heap = 99;
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: modificar todas las variables */
global = 100;
local = 200;
*heap = 300;
printf("Hijo: global=%d local=%d heap=%d\n", global, local, *heap);
_exit(0);
}
/* Padre: esperar y verificar */
wait(NULL);
printf("Padre: global=%d local=%d heap=%d\n", global, local, *heap);
free(heap);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o herencia_Memoria herencia_Memoria.c
$ ./herencia_Memoria
Hijo: global=100 local=200 heap=300
Padre: global=42 local=10 heap=99
El hijo modifico todas las variables, pero el padre ve los valores originales. Esto confirma que la memoria se copio (con COW) y que las modificaciones son independientes.
3. Herencia de los descriptores
Los descriptores de archivo se comparten entre padre e hijo: el hijo hereda una copia de la tabla de descriptores, pero ambas tablas apuntan a las mismás estructuras del núcleo (los file descriptions). Esto significa que:
- Si el padre lee de un descriptor, la posicion de lectura avanza para el hijo también (y viceversa).
- Si el padre cierra un descriptor, el hijo sigue teniendo el suyo (apuntando a la misma estructura).
- Si ambos escriben en el mismo descriptor (por ejemplo, stdout), las escrituras se intercalan.
- Los flags del descriptor (Como O_APPEND, O_NONBLOCK) son compartidos: Sí uno los cambia, el otro los ve.
Esta comparticion es útil en algunos casos (por ejemplo, un servidor que crea un hijo para atender una conexion, y ambos comparten el socket) pero peligrosa en otros (por ejemplo, Sí ambos escriben en el mismo archivo sin coordinacion).
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include
int main(void)
{
/* Abrir un archivo */
int fd = open("/tmp/test_fd", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror("open"); return 1; }
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: escribir en el descriptor compartido */
const char msg[] = "Hijo escribio esto\n";
write(fd, msg, Sizeof msg - 1);
_exit(0);
}
/* Padre: esperar y escribir en el mismo descriptor */
wait(NULL);
const char msg[] = "Padre escribio esto\n";
write(fd, msg, Sizeof msg - 1);
close(fd);
/* Mostrar el archivo */
system("cat /tmp/test_fd");
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o herencia_fd herencia_fd.c
$ ./herencia_fd
Hijo escribio esto
Padre escribio esto
El hijo y el padre escriben en el mismo archivo, y las escrituras se acumulan (porque comparten la posicion de escritura). El hijo escribio primero, y el padre escribio después, porque espero al hijo. Si no hubiera esperado, las escrituras se habrian intercalado.
4. El problema del bufer de stdio
Este es el error más clasico y más confuso de fork. considera este programa:
#define _POSIX_C_SOURCE 200809L
#include
#include
int main(void)
{
printf("Hola "); /* Sin \\n al final */
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
printf("desde el hijo\\n");
_exit(0);
}
printf("desde el padre\\n");
wait(NULL);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o problema_stdio problema_stdio.c
$ ./problema_stdio
Hola desde el hijo
Hola desde el padre
¿Por que aparece "Hola" dos veces? Porque printf("Hola ") no se escribio inmediatamente: se guardo en el bufer de stdio (en la memoria del proceso). Cuando fork copio la memoria, el bufer también se copio. ASí, tanto el padre como el hijo tienen "Hola " en su bufer. Cuando cada uno hace printf con "\\n", el bufer se vacia, y "Hola " se escribe dos veces.
La solución es vaciado el bufer antes de fork, con fflush(stdout):
#define _POSIX_C_SOURCE 200809L
#include
#include
int main(void)
{
printf("Hola ");
fflush(stdout); /* vaciar el bufer ANTES de fork */
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
printf("desde el hijo\\n");
_exit(0);
}
printf("desde el padre\\n");
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o solucion_stdio solucion_stdio.c
$ ./Solucion_stdio
Hola desde el hijo
Hola desde el padre
Ahora "Hola " aparece una sola vez en cada linea, porque el bufer se vacio antes de fork, y cada proceso sólo tiene su propio texto en el bufer.
Siempre hay que llamar a fflush() (o usar \\n, Que vacia el bufer en modo linea) antes de fork, para evitar que el bufer se duplique. Esto es especialmente importante en programás que usan printf sin \\n, o que escriben en archivos.
El problema también ocurre con archivos: Sí escribes en un archivo con fwrite y luego haces fork, el bufer del archivo se duplica, y ambos procesos escriben los mismos datos. La solución es fflush() antes de fork, o usar write() directamente (Que no tiene bufer).
5. Variables de entorno
El hijo recibe una copia de las variables de entorno del padre. Las modificaciones que el hijo haga a su entorno (con setenv, putenv, unsetenv) no afectan al padre, porque son copias independientes.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
int main(void)
{
setenv("MI_VAR", "valor_original", 1);
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: modificar la variable */
setenv("MI_VAR", "Modificado_por_hijo", 1);
printf("Hijo: MI_VAR = %s\n", getenv("MI_VAR"));
_exit(0);
}
wait(NULL);
printf("Padre: MI_VAR = %s\n", getenv("MI_VAR"));
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o herencia_entorno herencia_entorno.c
$ ./herencia_entorno
Hijo: MI_VAR = Modificado_por_hijo
Padre: MI_VAR = valor_original
El hijo modifico la variable, pero el padre ve el valor original. Esto confirma que el entorno se copio, no se compartio.
6. Directorio de trabajo y umask
El hijo hereda el directorio de trabajo (cwd) y la umask del padre. Pero, Como son copias, Sí el hijo cambia de directorio (chdir) o cambia la umask, el padre no se ve afectado.
Esto es útil para que el hijo pueda cambiar de directorio sin afectar al padre. Por ejemplo, un programa puede hacer fork, cambiar de directorio en el hijo, ejecutar un comando, y el padre sigue en su directorio original.
7. Senales
El hijo hereda los manejadores de senales del padre: Sí el padre tiene un manejador para SIGINT, el hijo también lo tiene. Pero la máscara de senales bloqueadas también se hereda.
Sin embargo, hay una excepción importante: las senales pendientes del padre no se heredan. Si el padre tiene senales pendientes (Que llegaron pero no se manejaron), el hijo no las recibe.
Además, el hijo no hereda los timers del padre (alarm, setitimer). Si el padre tenia un temporizador activo, el hijo no lo tiene.
8. Limites de recursos
El hijo hereda los limites de recursos (rlimit) del padre: Número maximo de archivos abiertos, tamano maximo de la pila, Número maximo de procesos, etc. Pero, Como son copias, Sí el hijo cambia un limite (con setrlimit), el padre no se ve afectado.
Esto es importante porque el hijo puede bajar sus limites (por ejemplo, limitar la memoria) Sin afectar al padre. Pero no puede subir los limites más alla del limite duro del padre.
9. Lo que no se hereda
Hay varios atributos que el hijo no hereda del padre:
| Atributo | Que pasa | Por que |
|---|---|---|
| PID | Se asigna uno nuevo | Cada proceso tiene un PID único. |
| PPID | Se establece al PID del padre | El hijo necesita saber quién es su padre. |
| Timers | No se heredan | Los temporizadores (alarm, setitimer) son especÃficos del proceso. |
| Bloqueos de archivo (flock) | No se heredan | Los bloqueos son especÃficos del proceso. |
| Senales pendientes | No se heredan | Las senales que llegaron pero no se manejaron no se transfieren. |
| Contadores de uso de recursos | Se resetean | Los contadores de CPU, E/S, etc. empiezan en cero para el hijo. |
| Memoria bloqueada (mlock) | No se hereda | Las páginas bloqueadas en RAM no se transfieren. |
10. El problema de los descriptores compartidos
La comparticion de descriptores de archivo es la fuente de más problemás en programás con fork. considera un servidor que abre un archivo de log, y luego hace fork para atender una conexion:
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include
int main(void)
{
int log_fd = open("/tmp/log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
if (log_fd < 0) { perror("open"); return 1; }
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: escribir en el log y salir */
const char msg[] = "Hijo escribio en el log\n";
write(log_fd, msg, Sizeof msg - 1);
_exit(0);
}
/* Padre: esperar y escribir en el log */
wait(NULL);
const char msg[] = "Padre escribio en el log\n";
write(log_fd, msg, Sizeof msg - 1);
close(log_fd);
system("cat /tmp/log.txt");
return 0;
}
En este caso, la comparticion es deseable: ambos escriben en el mismo archivo de log. Pero sí el hijo no termina (por ejemplo, es un proceso de larga duracion), el descriptor del log permanece abierto en el hijo, y el padre no puede cerrarlo. Y Sí el hijo hereda descriptores que no necesita (Como un socket de red), puede causar problemás.
La solución es cerrar los descriptores no necesarios en el hijo inmediatamente después de fork.
11. Cerrar descriptores en el hijo
Despues de fork, el hijo debe cerrar todos los descriptores que no necesita. Esto es especialmente importante en servidores, Dónde el hijo hereda descriptores de sockets, archivos de log, etc.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include
#include
static void cerrar_descriptores_excepto(int excepto)
{
DIR *d = opendir("/proc/self/fd");
if (!d) return;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (e->d_name[0] == ".") continue;
int fd = atoi(e->d_name);
if (fd != excepto && fd > 2)
close(fd);
}
closedir(d);
}
int main(void)
{
int fd = open("/tmp/datos.txt", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: cerrar todos los descriptores excepto stdin/stdout/stderr */
cerrar_descriptores_excepto(-1);
/* Ahora el hijo no tiene acceso al archivo */
printf("Hijo: descriptores cerrados\n");
_exit(0);
}
wait(NULL);
/* Padre: Sigue teniendo el descriptor */
char buf[256];
ssize_t n = read(fd, buf, Sizeof buf - 1);
if (n > 0) { buf[n] = 0; printf("Padre leyo: %s", buf); }
close(fd);
return 0;
}
La función cerrar_descriptores_excepto recorre /proc/self/fd y cierra todos los descriptores excepto los que se quieren conservar. Es un patron comun en servidores y demonios.
Una alternativa más moderna es usar fcntl(fd, F_SETFD, FD_CLOEXEC) para marcar descriptores con el flag O_CLOEXEC (close-on-exec), Que hace que se cierren automaticamente cuándo se ejecuta un nuevo programa con exec. El tema 17 profundiza en esto.
12. Un programa que demuestra la herencia
El siguiente programa demuestra varios aspectos de la herencia en un sólo ejemplo:
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include
int global = 42;
int main(void)
{
int local = 10;
setenv("MI_VAR", "original", 1);
printf("Antes de fork (Sin newline)");
fflush(stdout); /* vaciar bufer */
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: modificar todo */
global = 100;
local = 200;
setenv("MI_VAR", "Modificado", 1);
chdir("/tmp");
printf(" - hijo\\n");
printf("Hijo: global=%d local=%d MI_VAR=%s\\n",
global, local, getenv("MI_VAR"));
char buf[256];
getcwd(buf, Sizeof buf);
printf("Hijo: cwd=%s\\n", buf);
_exit(0);
}
wait(NULL);
printf(" - padre\\n");
printf("Padre: global=%d local=%d MI_VAR=%s\\n",
global, local, getenv("MI_VAR"));
char buf[256];
getcwd(buf, Sizeof buf);
printf("Padre: cwd=%s\\n", buf);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o herencia_completa herencia_completa.c
$ ./herencia_completa
Antes de fork (Sin newline) - hijo
Hijo: global=100 local=200 MI_VAR=Modificado
Hijo: cwd=/tmp
Antes de fork (Sin newline) - padre
Padre: global=42 local=10 MI_VAR=original
Padre: cwd=/home/ana/procesos
El programa demuestra que: la memoria se copia (global y local son independientes), el entorno se copia (MI_VAR es independiente), y el directorio de trabajo se copia (cwd es independiente). El fflush antes de fork evita que "Antes de fork (Sin newline)" se duplique.
13. Resumen
- El hijo recibe una copia de la memoria del padre (con copy-on-write).
- Los descriptores de archivo se comparten: ambas tablas apuntan a las mismás estructuras del núcleo.
- El bufer de stdio se duplica: hay que llamar a fflush() antes de fork.
- Las variables de entorno se copian: las modificaciones del hijo no afectan al padre.
- El directorio de trabajo y la umask se copian.
- Los manejadores de senales y los limites de recursos se copian.
- No se heredan: PID, PPID, timers, bloqueos de archivo, senales pendientes.
- Los descriptores compartidos pueden causar problemas: cerrar los no necesarios en el hijo.
- Usar O_CLOEXEC para que los descriptores se cierren automaticamente con exec.
14. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. Qué se copia y que se comparte en fork?
Se copia la memoria (con COW), las variables de entorno, el cwd, la umask, las senales y los limites. Se comparten los descriptores de archivo (apuntan a las mismás estructuras del núcleo).
2. Por que aparece printf dos veces después de fork?
Porque el bufer de stdio se duplica con fork. Si printf no vacio el bufer antes de fork, tanto el padre como el hijo tienen el mismo texto en el bufer, y ambos lo escriben al vaciarlo.
3. Cómo se evita el problema del bufer de stdio?
Llamando a fflush() antes de fork, o usando \\n en printf (Que vacia el bufer en modo linea).
4. Los descriptores de archivo se copian o se comparten?
Se comparten: el hijo hereda una copia de la tabla de descriptores, pero ambas tablas apuntan a las mismás estructuras del núcleo. Si uno lee, la posicion de lectura avanza para el otro.
5. Qué no se hereda en fork?
PID, PPID, timers, bloqueos de archivo (flock), senales pendientes, contadores de uso de recursos, y memoria bloqueada (mlock).
6. Por que es importante cerrar descriptores en el hijo?
Porque el hijo hereda todos los descriptores del padre, incluyendo los que no necesita. Si no los cierra, el padre no puede cerrarlos (porque el hijo los mantiene abiertos), y puede haber fugas de recursos.
7. Qué es O_CLOEXEC?
Un flag que se puede poner en un descriptor con fcntl(fd, F_SETFD, FD_CLOEXEC). Hace que el descriptor se cierre automaticamente cuándo se ejecuta un nuevo programa con exec. Es la forma recomendada de evitar heredar descriptores.
8. Las variables de entorno se comparten o se copian?
Se copian: el hijo recibe una copia independiente. Las modificaciones del hijo no afectan al padre.
9. Qué pasa con los manejadores de senales en el hijo?
El hijo hereda los manejadores de senales del padre. Pero las senales pendientes (Que llegaron pero no se manejaron) no se heredan.
10. Por que el hijo no hereda los timers del padre?
Porque los temporizadores (alarm, setitimer) son especÃficos del proceso. Si se heredaran, ambos procesos recibirian la senal cuándo el timer expirara, lo que causaria confusion.
Para prácticar
- Compila y ejecuta problema_stdio.c. Observa que "Hola" aparece dos veces. Luego compila solucion_stdio.c y verifica que se soluciona.
- Compila y ejecuta herencia_Memoria.c. Verifica que padre e hijo tienen variables independientes.
- Compila y ejecuta herencia_fd.c. Observa que ambos escriben en el mismo archivo.
- Compila y ejecuta herencia_completa.c. Verifica que memoria, entorno y cwd son independientes.
- Escribe un programa que abra un archivo, haga fork, y en el hijo cierre el descriptor. Verifica que el padre sigue teniendo el descriptor.
- Usa strace -f para ver como fork copia los descriptores.