Ejemplos java y C/linux

Tutoriales

Enlaces

Licencia

Creative Commons License
Esta obra está bajo una licencia de Creative Commons.
Para reconocer la autoría debes poner el enlace https://old.chuidiang.org

Procesos e Hilos en C de Unix/Linux

Vamos a ver lo básico de hilos y procesos. Las diferencias entre ellos y algún ejemplo sencillo de cada. Vermos la función fork() para procesos y los hilos de la librería POSIX.

Procesos y Threads (hilos de ejecución)

Si queremos que nuestro programa empiece a ejecutar varias cosas "a la vez", tenemos dos opciones. Por una parte podemos crear un nuevo proceso y por otra, podemos crear un nuevo hilo de ejecución (un thread). En realidad nuestro ordenador, salvo que tenga varias cpu, no ejecutará varias cosas a la vez. Cuando digo "a la vez", me refiero a que el sistema operativo irá ejecutando cachos de programa por turnos (por rodajas de tiempo) de forma muy rápida, dando la sensación de simultaneidad.

¿Cual es la diferencia entre proceso e hilo?

Un proceso de unix es cualquier programa en ejecución y es totalmente independiente de otros procesos. El comando de unix ps nos lista los procesos en ejecución en nuestra máquina. Un proceso tiene su propia zona de memoria y se ejecuta "simultáneamente" a otros procesos. Es totalemente imposible en unix que un proceso se meta, a posta o por equivocación, en la zona de memoria de otro proceso. Esta es una de las caracteristicas que hace de unix un sistema fiable. Un programa chapucero o malintencionado no puede fastidiar otros programas en ejecución ni mucho menos a los del sistema operativo. Si el programa chapucero se cae, se cae sólo él.

Dentro de un proceso puede haber varios hilos de ejecución (varios threads). Eso quiere decir que un proceso podría estar haciendo varias cosas "a la vez". Los hilos dentro de un proceso comparten todos la misma memoria. Eso quiere decir que si un hilo toca una variable, todos los demás hilos del mismo proceso verán el nuevo valor de la variable. Esto hace imprescindible el uso de semáforos o mutex (EXclusión MUTua, que en inglés es al revés, funciones pthread_mutex...) para evitar que dos threads accedan a la vez a la misma estructura de datos. También hace que si un hilo "se equivoca" y corrompe una zona de memoria, todos los demás hilos del mismo proceso vean la memoria corrompida. Un fallo en un hilo puede hacer fallar a todos los demás hilos del mismo proceso.

Un proceso es, por tanto,  más costoso de lanzar, ya que se necesita crear una copia de toda la memoria de nuestro programa. Los hilos son más ligeros.

En cuanto a complejidad, en los hilos, al compartir la memoria y los recursos, es casi obligado el uso de mutex o semáforos, así que su programación suele ser más complicada y se necesita ser más cuidadoso. Un proceso, en el momento de lanzarlo, se hace independiente del nuestro, así que no deberíamos tener ningún problema, salvo que necesitemos comunicación entre ellos, que nos liaríamos a programar memorias compartidas (con sus correspondientes semáforos), colas de mensajes, sockets o cualquier otro mecanismo de comunicación entre procesos unix.

 ¿Qué elegimos? ¿Un proceso o un hilo?. Depende de muchos factores, pero yo (y es cosa mia, que tengo un PC obsoleto con linux), suelo elegir procesos cuando una vez lanzado el hijo no requiero demasiada comunicación con él. Elijo hilos cuando tienen que compartir y actualizarse datos. Por ahí he leido que para gestionar entradas/salidas es mejor procesos (atender simultaneamente a varias entradas de sockets, por ejemplo) y que para hacer programas con muchos cálculos en paralelo con varias cpu es mejor hilos, siempre y cuando el sistema operativo sea capaz de repartir automáticamente los hilos en las distintas cpu en función de su carga de trabajo.

Ejemplo de programación de procesos

Vamos a hacer un pequeño ejemplo de programación de procesos. Intentaremos con el ejemplo, además de lanzar un nuevo proceso, comprobar que efectivamente las variables "se duplican". Más adelante veremos cómo comunicar padre e hijo.

La función C que crea un nuevo proceso es fork(). ¡Qué suerte!, tiene mucha miga, pero no lleva parámetros

Cuando llamamos a fork(), en algún lugar dentro de la función, se duplican los procesos y empiezan a correr por separado. Cuando llega el momento de retornar de dicha función (y ya tenemos dos procesos), al proceso original le devuelve un identificador del proceso recien creado (el mismo numerito que vemos con el comando ps de una shell). Al proceso recien creado le devuelve 0. Échale un ojo a la figura para entenderlo mejor.

Interioridades de fork() y creación de un nuevo proceso
Grafico de tiempo de fork()
En rojo el código que habría en nuestro programa.
En azul los procesos que irían corriendo (el tiempo transcurre de arriba a abajo).

El motivo de hacer esto así es que cada proceso pueda saber si es el original (proceso padre) o el nuevo (proceso hijo) y poder hacer así cosas distintas. Si metemos el fork() en un if, como en la figura, el proceso original sigue por la parte del else y el nuevo proceso por el then. De esta manera, el proceso original, por ejemplo, podría seguir atendiendo nuevos clientes que quieran conectarse a nuestro programa por un socket, mientras que el proceso hijo podría atender a un cliente que acaba de conectarse y que es el que ha provocado que lancemos el fork().

A partir de aquí ya podemos programar normalmente. Hay que tener en cuenta que se ha duplicado todo el espacio de memoria. Por ello, ambos procesos tienen todas las variables repetidas, pero distintas. Si el proceso original toca la variable "contador", el proceso hijo no verá reflejado el cambio en su versión de "contador".

Otro tema a tener en cuenta es que si antes del fork() tenemos, por ejemplo, un fichero abierto (un fichero normal, un socket, una pipa o cualquier otra cosa), después del fork() ambos procesos tendrán abierto el mismo fichero y ambos podrán escribir en él. Es más, uno de los procesos puede cerrar el fichero mientras que el otro lo puede seguir teniendo abierto.

La funcion fork() también puede devolver -1 en caso de error. Si esto ocurre, no se ha creado ningún nuevo proceso. El ejemplo de la figura no sería válido, porque el if ( fork() ), en caso de devolver -1, se iría por el else, pero en realidad no se ha creado ningún proceso hijo. Hay dos posibilidades: meter el fork() en un switch o guardarse la vuelta de fork() en una variable y luego hacer varios if. El ejemplo del switch sería

switch (fork())
{
    case -1:
        /* Código de error */
        ...
        break;
    case 0:
        /* Código del proceso hijo */
        ...
        break;
    default:
        ...
        /* Código del proceso original */
}

Una vez lanzado el proceso hijo, el padre puede "esperar" que el hijo termine. Para ello tenemos la función wait(). A la función wait() se le pasa la dirección de un entero para que nos lo devuelva relleno. La función wait() deja dormido al proceso que la llama hasta que alguno de sus procesos hijo termina, es decir, si llamamos a wait(), nos quedamos ahí parados hasta que el hijo termine. A la salid, en el entero tendremos guardada información de cómo ha terminado el hijo (ha llamado a un exit(), alguien le ha matado, se ha caido, etc).

En el ejemplo, al llamar a wait() ...

int estadoHijo;
...
wait (&estadoHijo);

... el proceso original (padre), se queda dormido hasta que el nuevo proceso (hijo) termina.

Una vez que salimos del wait(), tenemos unas macros que nos permiten evaluar el contenido de estadoHijo. Hay varias, pero un par de ellas más o menos útiles son:

Por ello, podemos hacer algo como

if ( WIFEXITED(estadoHijo) != 0)
{
    printf ("Mi hijo ha hecho exit (%d)\n", WEXITSTAUS (estadoHijo));
}

En el código de ejemplo pfork.c tienes exactamente esto. Puedes descargarlo, quitarle la extensión .txt, compilarlo con make pfork (sin Makefile) o con gcc pfork.c -o pfork. Si lo ejecutas verás en pantalla lo que va pasando. En otra terminal y siendo un poco hábil, puedes poner ps para ver los procesos que van corriendo. Verás que primero hay un pfork y que luego hay dos.

Además se ha declarado en el código una variable int variable = 1. El proceso hijo cambia el valor de dicha variable por un valor 2 y sale. El padre espera que muera el hijo y escribe en pantalla el valor del exit() del hijo y el valor de la variable, que para él permanece inalterada, sigue siendo 1.

El siguiente paso es ver cómo comunicar padre e hijo.

Ejemplo de programación de Hilos

Como ejemplo de hilos, vamos a hacer un programa que cree un hilo. Luego, tanto el programa principal como el hilo se meterán en un bucle infinito. El primero se dedicará a incrementar un contador y escribir su valor en pantalla. El hilo decrementará el mismo contador y escribirá su valor en pantalla. El resultado es que veremos en pantalla el contador incrementándose y decrementándose a toda velocidad.

Mas adelante veremos cómo hacer que un hilo espere a otro o como sincronizarlos para acceso a estructuras de datos.

La función que nos permite crear un un nuevo hilo de ejcución es pthread_create() que admite cuatro parámetros:

La función pthread_create() devuelve 0 si todo ha ido bien. Un valor distinto de 0 si ha habido algún problema y no se ha creado el thread.

El código de creación del thread quedaría.

void *funcionDelThread (void *);
...
pthread_t idHilo;
...
pthread_create (&idHilo, NULL, funcionDelThread, NULL);

Ya está creado el thread. Ahora nuestro programa principal seguirá a lo suyo y la funciónDelThread() se estará ejecutando "simultáneamente".

Hay un pequeño detalle a tener en cuenta. En linux de PC todo esto funciona correctamente (al menos en el mio). Sin embargo, hay otros microprocesadores/sistemas operativos que requieren que los threads "colaboren" entre ellos. Un ejemplo es Sparc/Solaris. El sistema operativo pone en marcha uno de los threads y no le quita el control hasta que él lo diga. Los demás threads quedan parados hasta que el primero "ceda el control". Para ceder el control suele haber funciones estilo yield(), thr_yield(), pthread_delay_pn(), etc. También se cede el control si se hacen llamadas que dejen dormido al thread en espera de algo, como wait(), sleep(), etc. Así que ya sabes, si ejecutas el ejemplo y ves que el contador sólo se incrementa o sólo se decrementa, quizás haya que poner usleep (1) dentro de los bucles, después del printf().

Puedes descargar pthread.c, el Makefile, quitarles la extensión .txt y compilar con make pthread o bien gcc pthread.c -lpthread -o pthread. Necesitas linkar con la libreria libpthread.a, de ahí la necesidad de Makefile o del -lpthread. Si lo ejecutas verás en pantalla el valor del contador incrementándose durante un rato, hasta que entre en ejecución el segundo hilo. A partir de ese momento, se incrementará y decrementará a toda velocidad. Tendrás que pararlo con Ctrl-C.

Estadísticas y comentarios

Numero de visitas desde el 4 Feb 2007:

Aviso Legal