Sistemas Operativos Ingenierı́a de telecomunicaciones Sesión 2: Procesos e hilos (modificado 29/10) Calendario Comienzo: Lunes 19 de octubre y miércoles 21 de octubre. Entrega: 2 de noviembre y 4 de noviembre, hasta la hora de clase. 1. Objetivos Al finalizar esta sesión: Comprenderás el mecanismo de creación de un proceso en un entorno UNIX real. Conocerás el funcionamiento y las opciones más comunes de los comandos que proporcionan información sobre el estado de los procesos: top y ps. Además, interpretarás correctamente la información que proporcionan. Conocerás las llamadas al sistema relacionadas con la creación de procesos y llamadas que devuelven información adicional como su PID o el de su padre. Escribirás programas en C que creen procesos en un entorno real UNIX y que esperen a que mueran. Identificarás las ventajas y los incovenientes derivados de los sistemas concurrentes. 2. Identificando un proceso Seguro que has leı́do una y otra vez la definición de un proceso, y seguro que la has entendido ;) Seguro que sabes qué es un proceso, pero ¿sabes dónde está un proceso? ¿Podrı́as identificar un proceso en tu ordenador ahora mismo? Cuando arrancas un programa en tu ordenador, por ejemplo un navegador web, el reproductor de música o el messenger, el sistema operativo empieza a crear procesos. Es 1 posible que un solo programa arranque varios procesos, pero por lo general arrancar un programa crea un único proceso. Consulta la página man del comando ps y piensa para qué puede servir. Vamos a ver qué procesos existen en nuestro ordenador ahora mismo. Abre un programa que no tengas abierto todavı́a: firefox, gedit, kedit, cualquiera. Ahora ejecuta el comando ps aux. Fı́jate en la salida del comando y busca el nombre del programa que acabas de ejecutar. ¿Cuántas veces aparece? ¿Sabrı́as interpretar la información que te proporciona el comando? Existe otro comando, top, que te dará información adicional acerca del estado en el que se encuentran los procesos. Además, este comando incluye información sobre los recursos del sistema. La única diferencia es que top proporciona información que se actualiza automáticamente. 3. Programando Vamos a ver cómo podemos gestionar los procesos creando nuestros propios programas. 3.1. Creando nuestro primer proceso Vamos a crear un programa muy simple y a comprobar cómo cuando lo arrancamos, estamos creando un nuevo proceso en el sistema. Hagamos un programa muy simple que no termine nunca: int main ( ) { while ( 1 ) { }; return 0 ; } Esto lo guardaremos en un archivos main.c. Para compilarlo ejectuamos: $>gcc main.c -o main con lo que obtenemos un ejecutable llamado main. Si lo invocamos desde la lı́nea de comandos veremos cómo la terminal se queda parada, sin responder a ninguna petición más. Para parar el programa que acabamos de arrancar pulsamos ctl+c (la tecla de control y la tecla ’c’ a la vez). Ahora lo lanzaremos de la siguiente manera: $>main & 2 El ampersand (&) le dice al intérprete de comandos que ejecute el proceso “en el fondo” y que siga interpretando comandos mientras tanto, sin esperar a que nuestro programa termine. Ahora hacemos de nuevo un ps: PID PPID USER COMMAND 1330 1158 fherrero bash 1341 1330 fherrero main Lo que nos interesa ahora es saber cómo nuestro proceso puede crear otros procesos igual que hace bash. 3.2. Clones En un programa nosotros utilizamos datos que guardamos en variables y funciones que operan sobre esos datos. Cuando ejecutamos un programa creamos un proceso que, entre otras cosas, tiene una zona de la memoria del ordenador reservada para él donde guarda nuestros datos y las instrucciones que ha de ejecutar. Si en nuestro programa llamamos a fork(), le estaremos pidiendo al sistema operativo que cree un nuevo proceso exactamente igual al que llama a fork(). Primero vamos a crear un programa que recoge información como ps: #include <s y s / t y p e s . h> #include <u n i s t d . h> #include <s t d i o . h> int main ( ) { p r i n t f ( ‘ ‘ \ nHola , soy e l p r o c e s o %d y mi padre e s e l %d\n ’ ’ , getpid () , getppid () ) ; return 0 ; } Compiladlo y ejecutadlo como siempre. Ahora veremos cómo funciona fork() #include <s y s / t y p e s . h> #include <u n i s t d . h> #include <s t d i o . h> int main ( ) { fork () ; p r i n t f ( ‘ ‘ \ nHola , soy e l p r o c e s o %d y mi padre e s e l %d\n ’ ’ , getpid () , getppid () ) ; 3 return 0 ; } Compiladlo y ejecutadlo. Ahora obtenemos algo parecido a: Hola, soy el proceso 1431 y mi padre es el 1330 Hola, soy el proceso 1432 y mi padre es el 1431 Veamos qué está sucediendo. Cuando invocamos a main, creamos un proceso con PID 1431 en este caso. La llamada a fork() hace que el sistema cree un proceso nuevo exactamente igual al anterior. Esto implica entre otras cosas, que el código que ejecutan es exactamente igual. Hay que tener muy claro que después de fork() son dos los procesos ejecutando nuestro programa. Es por eso que la lı́nea printf(...) se muestra dos veces en la terminal, una por cada proceso. Más aún, se puede ver que en una de las lı́neas un proceso dice: “...mi padre es el 1431”. Este proceso es el que fork() ha creado, y que no existı́a antes de él. El proceso 1431 es en este caso el padre y el 1432 el hijo. 3.3. Distinguiendo entre padres e hijos No parece que fork() sea muy útil si los dos procesos van a hacer exactamente lo mismo. Es posible, sin embargo, que utilizando el mismo código, cada proceso tome una decisión diferente sobre lo que debe hacer. Probad el siguiente código: #include #include #include #include <s y s / t y p e s . h> <u n i s t d . h> < s t d l i b . h> <s t d i o . h> void padre ( ) { p r i n t f ( ‘ ‘ \ nSoy e l padre con p i d %d\n ’ ’ , g e t p i d ( ) ) ; exit (0) ; } void h i j o ( ) { p r i n t f ( ‘ ‘ \ nSoy e l h i j o con p i d %d y padre %d\n ’ ’ , g e t p i d ( ) , %g e t p p i d ( ) ) ; exit (0) ; } int main ( ) { 4 p i d t pid = f o r k ( ) ; i f ( p i d == 0 ) /∗ Soy e l h i j o ∗/ { hijo () ; } i f ( p i d > 0 ) /∗ Soy e l padre ∗/ { padre ( ) ; } return 0 ; } La salida de este programa: Soy el hijo con pid 1471 y padre 1470 Soy el padre con pid 1470 Para más información sobre el funcionamiento de fork() consultad man fork. Es muy importante entender qué valor devuelve y cómo el padre y el hijo son capaces de decidir quién es quién. 3.4. Datos y memoria Hemos dicho que el proceso hijo es una copia exacta de su padre, lo que implica ejectuar un código igual y tener una estructura en memoria igual. En este programa vamos a crear tres procesos y a comprobar algunas cosas sobre la memoria. Compila, ejecuta, y analiza los resultados que obtengas. #include #include #include #include <s y s / t y p e s . h> <u n i s t d . h> < s t d l i b . h> <s t d i o . h> int main ( ) { int x = 3 ; p i d t pid = f o r k ( ) ; i f ( p i d == 0 ) /∗ Soy e l h i j o ∗/ { p r i n t f ( ‘ ‘ Soy e l h i j o . X v a l e a l p r i n c i p i o : %d\n ’ ’ , x ) ; 5 x = x − 1; p i d t pid2 = f o r k ( ) ; i f ( p i d 2 == 0 ) /∗ Soy e l h i j o d e l h i j o : e l n i e t o ∗/ { p r i n t f ( ‘ ‘ Soy e l n i e t o . X v a l e : %d\n ’ ’ , x ) ; exit (0) ; } i f ( p i d 2 > 0 ) /∗ Soy e l padre d e l n i e t o : e l h i j o ∗/ { p r i n t f ( ‘ ‘ Soy e l h i j o . X v a l e : %d\n ’ ’ , x ) ; exit (0) ; } } i f ( p i d > 0 ) /∗ Soy e l padre ∗/ { p r i n t f ( ‘ ‘ Soy e l padre . X v a l e : %d\n ’ ’ , x ) ; exit (0) ; } return 0 ; } 3.5. Cuando terminan los hijos Probad qué pasa cuando el padre termina y el hijo sigue trabajando. Haced $>ps -eo pid,ppid,comm | grep main después de ejecutar vuestro programa. ¿Qué ha pasado con el padre? Si el padre muere, ¿Cuál es el ppid del proceso hijo? #i n c l u d e <s y s / t y p e s . h> #i n c l u d e <u n i s t d . h> int main ( ) { p i d t pid = f o r k ( ) ; i f ( p i d == 0 ) /∗ Soy e l h i j o ∗/ { while ( 1 ) ; } i f ( p i d > 0 ) /∗ Soy e l padre ∗/ 6 { } return 0 ; } Probemos ahora el otro caso: #i n c l u d e <s y s / t y p e s . h> #i n c l u d e <u n i s t d . h> int main ( ) { p i d t pid = f o r k ( ) ; i f ( p i d == 0 ) /∗ Soy e l h i j o ∗/ { return 0 ; } i f ( p i d > 0 ) /∗ Soy e l padre ∗/ { while ( 1 ) ; } return 0 ; } Para ejecutarlo y poder seguir trabajando con la shell sin esperar a que el padre muera, recordad poner ’&’ al final: $>main &. Comprobad de nuevo con ps qué le ha pasado al hijo. Si todo ha funcionado como debiera, el proceso correspondiente al hijo estará marcado como zombie o difunto. Esto porque ha terminado mientras que el padre sigue trabajando. Para que el proceso hijo pueda alcanzar la paz eterna, es necesario que el padre recoja la información adecuada con wait(): #include <s y s / t y p e s . h> #include <u n i s t d . h> int main ( ) { p i d t pid = f o r k ( ) ; i f ( p i d == 0 ) /∗ Soy e l h i j o ∗/ { return 0 ; } 7 i f ( p i d > 0 ) /∗ Soy e l padre ∗/ { int c h i l d s t a t u s ; w a i t (& c h i l d s t a t u s ) ; while ( 1 ) ; } return 0 ; } El padre puede utilizar wait() como mecanismo de comunicación con su hijo mediante la función exit() (man 3 exit). Para ver información sobre wait(), busca en la sección 2 del manual: man 2 wait. 4. Otra forma de paralelismo: hilos Casi todos los sistemas modernos soportan un nuevo modelo de paralelismo llamado “hilos” o “hebras” según la traducción del inglés “thread”. Un hilo es un flujo de control adicional dentro de un mismo proceso. Podemos pensar que un proceso es la entidad a la que se asocian los recursos, y los hilos son ejecuciones paralelas del programa que comparten los mismos recursos. Veamos un ejemplo: #include <p t h r e a d . h> #include <s t d i o . h> void ∗ f u n c i o n h i l o ( void ∗ a r g s ) { int ∗ v a r i a b l e = ( int ∗ ) a r g s ; int i ; for ( i = 0 ; i < 1 0 ; i ++){ ∗ variable = i ; sleep (3) ; } } int main ( ) { pthread t nuevo hilo ; int v a r i a b l e = 0 ; 8 p t h r e a d c r e a t e (& n u e v o h i l o , NULL, f u n c i o n h i l o , &v a r i a b l e ) ; int i ; for ( i = 0 ; i < 1 0 ; i ++){ p r i n t f ( ‘ ‘ V a r i a b l e v a l e : %d\n ’ ’ , v a r i a b l e ) ; sleep (1) ; } return 0 ; } Aquı́ tenemos un único proceso, con dos hilos de ejecución (primero un único hilo, creamos el segundo con pthread create()). El hilo original imprime una variable cada segundo y el segundo hilo la modifica cada tres segundos. Daos cuenta que la variable que estamos modificando es la misma, en el sentido de que es la misma posición de memoria, que la que imprime el otro hilo. Al igual que con los procesos, existe una manera en la que el hilo principal puede esperar a que los demás hilos mueran: pthread join(). La diferencia es que pthread join espera a que termine un hilo en concreto. 5. Comprobación de errores En ninguno de los ejemplos anteriores se han comprobado los posibles errores que pudieran aparecer. ¿Qué pasa si fork no puede crear un nuevo proceso? Deberéis buscar en las correspondientes páginas man cómo detectar errores. Por lo general las llamadas al sistema devuelven un número negativo en caso de error, pero no tiene por qué ser necesariamente ası́. Algunas de ellas devuelven valores negativos distintos en función del tipo de error. No será necesario hacer un control exhaustivo de todos los posibles errores, pero sı́ de los más comunes o probables. Más páginas man interesantes relacionadas con el control de errores: errno y perror, en la sección 3 del manual. 6. Ejercicios 6.1. Conocimiento Cada pregunta vale 1 punto. Serán valoradas como correctas o incorrectas. 1. Encuentra un comando que presente todos los procesos ejecutándose actualmente en el sistema según su relación padre → hijo. 9 2. ¿Existe algún proceso que no tenga ancestros? Especifica cuál. 3. En Unix existe un mecanismo para que los procesos se pasen mensajes simples entre sı́ llamado “señales”. ¿Qué comando permite mandar una señal a cualquier proceso desde la shell ? Pon un ejemplo de su uso donde se vea cómo un proceso reacciona a la señal que le mandas. 4. El comando top, por defecto, sólo muestra información sobre procesos pero no sobre hilos. ¿Cómo se pueden ver también hilos en top? Escribe un pequeño programa (que deberás incluir) que demuestre cómo top muestra efectivamente los hilos. 5. Fı́jate en una opción concreta del comando ps: -eo. Esta opción nos permite especificar qué campos queremos tener en la salida. Especifica qué argumentos hay que pasar a -eo para mostrar a la vez el PID de cada proceso, el de su padre, el usuario que lo ha ejecutado y el comando con el que se ejecutó. 6. Abre una terminal. Desde ahı́ arranca un firefox. Con la ayuda del comando ps, investiga el proceso de creación del navegador, indicando todos sus ancestros. 7. Fı́jate en el resto de procesos y encuentra alguno que sea padre de varios procesos. Escribe qué proceso es, y, si puedes, qué función tiene. Identifica también sus ancestros. 8. Después de todas estas pruebas, describe con tus propias palabras qué es un proceso. ¿Ha cambiado en algo tu percepción respecto a lo que habı́as visto en teorı́a? ¿Qué te ha aportado? ¿Qué dudas te quedan todavı́a? 6.2. Experimentación 1. El proceso es la unidad fundamental de asignación de recursos del sistema operativo. Es decir, siempre que un usuario adquiere un recurso, lo hace asociado a un determinado proceso. Existe una llamada al sistema, getrusage() que permite conocer los recursos utilizados por un proceso o sus hijos. Consulta su página man y familiarı́zate con ella. Escribe ahora un programa que haga lo siguiente (1 punto): a) Debe recibir como argumento al ejecutarse el número de hijos que debe tener. b) Cada hijo reservará un bloque de memoria de un millón de bytes con malloc(). Después rellenará cada una de las posiciones con un número aleatorio generado con “rand()”. Finalmente terminará. c) El proceso padre deberá esperar a que todos sus hijos mueran y finalmente recoger sus propias estadı́sticas y las de sus hijos con getrusage(). d ) Deberá imprimir por pantalla las estadı́sticas recogidas. 2. Analiza el comportamiento de los recursos en función del número de hijos y el tiempo que duermen. (1 punto) 10 6.3. Programación 1. Crea un programa que sea capaz de leer de teclado y acepte las siguientes órdenes (2 puntos): CREAR: creará un nuevo proceso que no hará más que ejecutar un bucle infinito. MATAR pid: si el proceso con PID pid es hijo suyo lo matará y comprobará que termina correctamente. Si no (porque no es hijo suyo o porque no existe tal proceso), informará al usuario convenientemente. LISTAR: mostrará una lista de todos los PIDs de sus hijos. SALIR: matará a todos los hijos que queden vivos, comprobando que terminan correctamente (hay que comprobarlo en el programa, no posteriormente) y terminará. El programa deberá estar leyendo de teclado y aceptando órdenes hasta que reciba la orden SALIR. Se puede suponer que el lı́mite máximo de procesos que podrá crear será 100. 2. Repetir el mismo ejercicio con hilos (1 punto). 3. Ejecutar los dos ejercicios y demostrar, utilizando las herramientas del sistema, que realmente se están creando y eliminando los procesos/hilos. (0,5 puntos) 11