Fork me on GitHub

klashxx    Archive    About    Talks    Feed

awk, power para tu command line

Guía de uso de la navaja suiza de los *nix para principiantes


English version here.


:warning: Disclaimer :warning:

Este texto se refiere exclusivamente a la implementación GNU de awk conocida como gawk que es la usada mayoritariamente en cualquier distribución Linux actual.

En su redacción la referencia fundamental ha sido The GNU Awk User’s Guide y mis aportaciones a Stackoverflow.


AWK es un lenguaje similar a PERL, pero considerablemente más elegante.

  • Arnold Robbins

¿Qué es?

AWK es un lenguaje de programación diseñado para procesamiento de texto, se usa normalmente como herramienta de extracción y reporting.

Es un standard en prácticamente cualquier sistema *nix.

awkaward name …

El nombre del lenguaje se deriva del apellido de sus autores: Alfred Aho, Peter Weinberger, y Brian Kernighan.

Así que awk

  • Busca líneas que contengan determinados patrones en ficheros o en la entrada estándar.

  • Se usa fundamentalmente para reporting y extracción de datos, por ejemplo para sumarizar la salida de otros programas.

  • Tiene una sintaxis similar a C.

  • Orientado a los datos: se trata de describir con que datos queremos trabajar y que acción hacer al encontrarlos.

pattern { action }
pattern { action }

Lo básico

¿Cómo ejecutarlo?

Si el programa es corto:

awk 'program' input-file1 input-file2

:warning: Nota: presta atención a los posibles problemas con el shell quoting1.

cmd | awk 'program'

El pipe redirige la salida del comando a la izquierda cmd a la entrada del comando awk2.

Cuando el código es largo, normalmente es preferible usar un fichero como contendor y ejecutarlo de esta forma:

awk -f program-file input-file1 input-file2

cmd | awk -f program-file

O a través de un interprete, a lo shebang.

#!/bin/awk -f

BEGIN { print "hello world!!" }

Otros parámetros interesantes

-F fs Establece el valor de FS (Field Separator) fs.

-v var=val Pasa la variable var con valor val al awk antes de que la ejecución comience.

:point_right: Nota: se puede usar múltiples veces, para setear el número deseado de valores.

BEGIN y END

Estas etiquetas marcan los bloques o patrones especiales que proporcionan un espacio para las acciones de inicialización y limpieza.

Sigue el siguiente esquema:

BEGIN{
    // initialize variables
}
{
    /pattern/ { action }
}
END{
    // cleanup
}

Ambos bloques se ejecutan solo una vez, BEGIN antes de que se lea el primer registro, END después de consumir todo el input.

$ echo "hello"| awk 'BEGIN{print "BEGIN";f=1}
                     {print $f}
                     END{print "END"}'
BEGIN
hello
END

Porque grepear si tenemos awk?

$ cat lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Maecenas pellentesque erat vel tortor consectetur condimentum.
Nunc enim orci, euismod id nisi eget, interdum cursus ex.
Curabitur a dapibus tellus.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aliquam interdum mauris volutpat nisl placerat, et facilisis.
$ grep dolor lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
$ awk '/dolor/' lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

:warning: Nota: Cuando no se proporciona una acción el comportamiento por defecto es volcar la línea o registro macheado al stdout.

Pero … ¿como podríamos mostrar la primera y ultima palabra de cada línea?

Se puede hacer con grep, por supuesto, pero necesitamos dos paso:

$ grep -Eo '^[^ ]+' lorem.dat
Lorem
Maecenas
Nunc
Curabitur
Lorem
Aliquam
$ grep -Eo '[^ ]+$' lorem.dat
elit.
condimentum.
ex.
tellus.
elit.
ultrices.

Veamos awk en acción solventado este problema:

$ awk '{print $1,$NF}' lorem.dat
Lorem elit.
Maecenas condimentum.
Nunc ex.
Curabitur tellus.
Lorem elit.
Aliquam ultrices.

¿Mejor no :sunglasses:? , Yeah but … ¿Como funciona?

Para averiguarlo es necesario conocer dos estructuras básicas en awk los Registros (Records) y Campos (Fields) en los que se dividen cualquier entrada al programa.


Registros

Los registros están delimitados por un carácter o expresión regular que se conoce como Record Separator RS.

Su valor por defecto es el salto de línea unix \n, por este motivo los registros por defecto equivalen a una línea individual.

Adicionalmente disponemos de la variable ORS Output Record Separator que nos va a permitir controlar la delimitación de estos registros al volcarlos sobre el stdout.

Tanto el RS como el ORS deben estar encomillados, lo que indica que estamos ante constantes literales.

Usar un carácter o regex distinto es tan simple como asignárselo a las variable RS u ORS:

  • Generalmente, el mejor momento para hacerlo es al comienzo de la ejecución, en el BEGIN, antes de que comience el proceso de la entrada de modo que el primer registro se lea con el separador deseado.

  • La otra forma de cambiar el RS (o el ORS) es a través de la línea de comando mediante los mecanismos de asignación de variables.

Ejemplos:

$ awk 'BEGIN{RS=" *, *";ORS="<<<---\n"}{print $0}' lorem.dat
Lorem ipsum dolor sit amet<<<---
consectetur adipiscing elit.
Maecenas pellentesque erat vel tortor consectetur condimentum.
Nunc enim orci<<<---
euismod id nisi eget<<<---
interdum cursus ex.
Curabitur a dapibus tellus.
Lorem ipsum dolor sit amet<<<---
consectetur adipiscing elit.
Aliquam interdum mauris volutpat nisl placerat<<<---
et facilisis neque ultrices.
<<<---
$ awk '{print $0}' RS=" *, *" ORS="<<<---\n" lorem.dat
Lorem ipsum dolor sit amet<<<---
consectetur adipiscing elit.
Maecenas pellentesque erat vel tortor consectetur condimentum.
Nunc enim orci<<<---
euismod id nisi eget<<<---
interdum cursus ex.
Curabitur a dapibus tellus.
Lorem ipsum dolor sit amet<<<---
consectetur adipiscing elit.
Aliquam interdum mauris volutpat nisl placerat<<<---
et facilisis neque ultrices.
<<<---

Campos

Los registros awk se dividen de forma automática en pedazos denominados campos (fields).

El separador se contiene en la variable FS (Field Separator) su valor por defecto entre campos es el espacio blanco que en awk se define como una cadena compuesta por uno o mas espacios, TABs o saltos de línea.

Nos referimos a un campo en awk mediante el símbolo dólar $ seguido por el número del campo que deseamos tratar.

De esta forma $1 se referirá al primer campo, $2 al segundo y así sucesivamente.

IMPORTANTE: $0 hace referencia al registro completo.

$ awk '{print $3}' lorem.dat
dolor
erat
orci,
dapibus
dolor
mauris

NF es una variable predefinida que devuelve el número de campos de el registro actual.

En la práctica esto trae como consecuencia que $NF siempre apuntará al último campo de un registro.

$ awk '{print NF, $NF}' lorem.dat
8 elit.
7 condimentum.
10 ex.
4 tellus.
8 elit.
10 ultrices.

Del mismo modo que existe un ORS también disponemos de un OFS (Output Field Separator) para controlar la forma en que delimitaremos los campos al mandarlos al output stream.

$ cat /etc/group
nobody:*:-2:
nogroup:*:-1:
wheel:*:0:root
daemon:*:1:root
kmem:*:2:root
sys:*:3:root
tty:*:4:root
$ awk '!/^(_|#)/&&$1=$1' FS=":" OFS="<->" /etc/group
nobody<->*<->-2<->
nogroup<->*<->-1<->
wheel<->*<->0<->root
daemon<->*<->1<->root
kmem<->*<->2<->root
sys<->*<->3<->root
tty<->*<->4<->root

:warning: Nota: ¿¿Y ese?? … $1=$13


Una vez que interiorizamos registros y campos resulta muy sencillo entender nuestro código inicial:

$ awk '{print $1,$NF}' lorem.dat
Lorem elit.
Maecenas condimentum.
Nunc ex.
Curabitur tellus.
Lorem elit.
Aliquam ultrices.

NR y FNR

Estas son dos variables built-in muy interesantes:

NR : Número de registros que awk ha procesado desde el inicio de la ejecución del programa.

FNR : Número de registro del fichero procesado actualmente, awk resetea FNR a cero cada vez que comienza a procesar un nuevo archivo.

$ cat n1.dat
one
two
$ cat n2.dat
three
four
$ awk '{print NR,FNR,$0}' n1.dat n2.dat
1 1 one
2 2 two
3 1 three
4 2 four

Mejorando la salida

Para conseguir una salida más adecuada podemos usar printf:

printf format, item1, item2, …

La mascara de formato es muy similar a la del ISO C.

$ awk '{printf "%20s <-> %s\n",$1,$NF}' lorem.dat
               Lorem <-> elit.
            Maecenas <-> condimentum.
                Nunc <-> ex.
           Curabitur <-> tellus.
               Lorem <-> elit.
             Aliquam <-> ultrices.

Redirigiendo el Output

La salida de print y printf se dirige por defecto al stdout pero podemos redireccionarla de diferentes modos.

Estas redirecciones en awk se escriben de forma similar a como se hacen en los comandos sobre shell, con la salvedad de que se incorporan en el código del programa.

$ awk 'BEGIN{print "hello">"hello.dat"}'
$ awk 'BEGIN{print "world!">>"hello.dat"}'
$ cat hello.dat
hello
world!

Otra posibilidad es enviar la salida a otro programa mediante pipes:

$ awk 'BEGIN{sh="/bin/sh";print "date"|sh;close(sh)}'
dom nov 13 18:36:25 CET 2016

Podemos apuntar estas redirecciones a los streams estándar:

  • /dev/stdin: La entrada estándar (descriptor 0).

  • /dev/stdout: La salida estándar (descriptor 1).

  • /dev/stder: La salida de error estándar (descriptor 2).

Un ejemplo de como escribir mensajes de error sería:

$ awk 'BEGIN{print "Serious error detected!" > "/dev/stderr"}'
Serious error detected!

Trabajando con Arrays

En awk los arrays son asociativos lo que en la practica se traduce en que cada array es una colección de parejas índice - valor , siendo el índice cualquier valor numérico o cadena de texto, donde el orden es irrelevante.

No necesitan de declaración previa y se pueden añadir nuevos valores en cualquier momento.

Índice Valor
“perro” “dog”
“gato” “cat”
“uno” “one”
1 “one”
2 “two”

Para referirnos a un array usaremos la sintaxis:

array[index-expression]

Si queremos asignarle valores:

array[index-expression] = value

Para determinar si un índice está presente:

indx in array

Para recorrer los elementos del array:

for (var in array) {
    var, array[var]
    }

Si hemos usado valores numéricos podemos recuperar los elementos preservando el orden:

for (i = 1; i <= max_index; i++) {
    print array[i]
    }

O usar algo más avanzado (exclusivo de gawk) como @ind_str_asc.

Por ejemplo, partiendo de:

$ cat dict.dat
uno one
dos two
tres three
cuatro four
awk '{dict[$1]=$2}
     END{if ("uno" in dict)
           print "Yes we have uno in dict!"
         if (!("cinco" in dict))
           print "No , cinco is not in dict!"
         for (esp in dict){
            print esp, "->" ,dict[esp]
            }
     }'  dict.dat

Devolvería:

Yes we have uno in dict!
No , cinco is not in dict!
uno -> one
dos -> two
tres -> three
cuatro -> four

Podemos ver como mantiene el orden original:

awk 'BEGIN{
      a[4]="four"
      a[1]="one"
      a[3]="three"
      a[2]="two"
      a[0]="zero"
      exit
      }
      END{for (idx in a){
             print idx, a[idx]
             }
      }'
4 four
0 zero
1 one
2 two
3 three

La ordenación podemos controlarla mediante va variable PROCINFO:

awk 'BEGIN{
      PROCINFO["sorted_in"] = "@ind_num_asc"
      a[4]="four"
      a[1]="one"
      a[3]="three"
      a[2]="two"
      a[0]="zero"
      exit
      }
      END{for (idx in a){
             print idx, a[idx]
             }
      }'
0 zero
1 one
2 two
3 three
4 four

Funciones Build-in

gensub(regexp, replacement, how [, target]) : Es la función más avanzada de substitución de texto.

Y sus variantes más simples:

gsub(regexp, replacement [, target])

sub(regexp, replacement [, target])

Su uso más simple permite sencillas substituciones

Partiendo de:

$ cat lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Maecenas pellentesque erat vel tortor consectetur condimentum.
Nunc enim orci, euismod id nisi eget, interdum cursus ex.
Curabitur a dapibus tellus.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aliquam interdum mauris volutpat nisl placerat, et facilisis.

Supongamos que deseamos invertir la posición de las palabras que se encuentran a un lado y otro de las comas.

$ awk '{print gensub(/([^ ]+)( *, *)([^ ]+)/,
                     "\\3\\2\\1", "g")}' lorem.dat
Lorem ipsum dolor sit consectetur, amet adipiscing elit.
Maecenas pellentesque erat vel tortor consectetur condimentum.
Nunc enim euismod, orci id nisi interdum, eget cursus ex.
Curabitur a dapibus tellus.
Lorem ipsum dolor sit consectetur, amet adipiscing elit.
Aliquam interdum mauris volutpat nisl et, placerat facilisis.

Mediante gensub usamos grupos para realizar tres capturas he invertir los resultados.

Una acción más simple sería la substitución de puntos por comas:

awk '$0=gensub(/\./, ",", "g")' lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
Maecenas pellentesque erat vel tortor consectetur condimentum,
Nunc enim orci, euismod id nisi eget, interdum cursus ex,
Curabitur a dapibus tellus,
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
Aliquam interdum mauris volutpat nisl placerat, et facilisis,

Aunque también podríamos optar por la función simplificada gsub:

awk 'gsub(/\./, ",")' lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
Maecenas pellentesque erat vel tortor consectetur condimentum,
Nunc enim orci, euismod id nisi eget, interdum cursus ex,
Curabitur a dapibus tellus,
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
Aliquam interdum mauris volutpat nisl placerat, et facilisis,

Personalmente la considero mas adecuada cuando no es necesario la captura de grupos en regex.

Otras funciones de procesamiento de cadenas interesantes son index y substr.

index(in, find)

substr(string, start [, length ])

Su funcionamiento queda claro con estos sencillos ejemplos:

$ awk 'BEGIN{t="hello-world";print index(t, "-")}'
6
$ awk 'BEGIN{t="hello-world";print substr(t,index(t, "-")+1)}'
world

La función split permite generar un array a partir de una cadena y un separador, retorna el número de elementos del vector resultante:

split(string, array [, fieldsep [, seps ] ])

$ cat passwd
jd001:x:1032:666:Javier Diaz:/home/jd001:/bin/rbash
ag002:x:8050:668:Alejandro Gonzalez:/home/ag002:/bin/rbash
jp003:x:1000:666:Jose Perez:/home/jp003:/bin/bash
ms004:x:8051:668:Maria Saenz:/home/ms004:/bin/rbash
rc005:x:6550:668:Rosa Camacho:/home/rc005:/bin/rbash
$ awk 'n=split($0, a, ":"){print n, a[n]}' passwd
7 /bin/rbash
7 /bin/rbash
7 /bin/bash
7 /bin/rbash
7 /bin/rbash

:point_right: Nota: Esto se podría hacer de otra forma mucho más simple.

$ awk '{print NF,$NF}' FS=':' passwd
7 /bin/rbash
7 /bin/rbash
7 /bin/bash
7 /bin/rbash
7 /bin/rbash

Funciones propias

Escribir funciones custom es muy simple tal y como se puede apreciar en el siguiente ejemplo:

awk 'function test(m)
     {
        printf "This is a test func, parameter: %s\n", m
     }
     BEGIN{test("param")}'

Que nos devuelve:

This is a test func, parameter: param

Análogamente podríamos devolver un valor mediante return:

awk 'function test(m)
     {
        return sprintf("This is a test func, parameter: %s", m)
     }
     BEGIN{print test("param")}'

La única forma de que una variable sea local en una función es en la recogida de parámetros.

Los parámetros escalares se pasan por valor y los arrays por referencia así que cualquier cambio que se haga en el array en la función se reflejara en el cuerpo del programa:

 awk 'function test(m)
      {
       m[0] = "new"
      }
      BEGIN{m[0]=1
            test(m)
            exit
      }
      END{print m[0]}'

Volcará al stdout:

new

Ahora empecemos con lo divertido :godmode:

Trataremos de resolver una serie de ejercicios que pondrán a prueba los conocimientos adquiridos:

01. Mostrar la penúltima palabra del fichero lorem.dat.

02. Substituir una línea o registro.

03. Añadir un punto y coma al final de cada línea.

04. ¿Y una coma entre cada palabra?

05. ¿Todo junto?

06. Incluir los registros impares en un archivo y los pares en otro.

07. Dado un fichero de /ect/password informa el campo del home del usuario en base al login.

08. Cambiar el orden de los campos de modo que el primero pase a ser el final.

09. Hackeando traceroute.

10. Procesos dependientes de un PID padre.

11. Como agregar información a partir de una clave.

12. Mostrar los registros entre dos patrones.

13. Convertir un campo en función de su contenido a un determinado valor numérico.

14. Agrupando registros en columnas.

15. Procesando un fichero FASTA.

16. Reporting complejo.

17. Join entre ficheros.

18. Cruzando /etc/passwd y /etc/group.

19. Conexiones por usuario a un servidor.

20. Obteniendo la media total proporcionada por el comando uptime.


:astonished: ¡Markdown no permite acentos en los anchors! :astonished:


01. Mostrar la penultima palabra de un fichero

Partiendo del siguiente source file:

$ cat lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Maecenas pellentesque erat vel tortor consectetur condimentum.
Nunc enim orci, euismod id nisi eget, interdum cursus ex.
Curabitur a dapibus tellus.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aliquam interdum mauris volutpat nisl placerat, et facilisis.
$ awk '{print $(NF-1)}' lorem.dat
adipiscing
consectetur
cursus
dapibus
adipiscing
neque

No hay mucho que explicar NF indica el número de campos presente en el registro , luego (NF-1) apuntara al campo anterior al último y $(NF-1) a su correspondiente valor.


02. Substituir un registro

Supongamos que necesitamos reemplazar la tercera línea por:

Esto no es latín

Nada más simple, basta con jugar con NR (Number of Record):

$ awk 'NR==3{print "Esto no es latín";next}{print}' lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Maecenas pellentesque erat vel tortor consectetur condimentum.
Esto no es latín
Curabitur a dapibus tellus.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aliquam interdum mauris volutpat nisl placerat, et facilisis.

Alternativamente, podemos evitar usar next asignando el nuevo texto al registro completo $0:

$ awk 'NR==3{$0="Esto no es latín"}1' lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Maecenas pellentesque erat vel tortor consectetur condimentum.
Esto no es latín
Curabitur a dapibus tellus.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aliquam interdum mauris volutpat nisl placerat, et facilisis.

03. Colocar un punto y coma al final de cada registro

$ awk '1' ORS=";\n" lorem.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.;
Maecenas pellentesque erat vel tortor consectetur condimentum.;
Nunc enim orci, euismod id nisi eget, interdum cursus ex.;
Curabitur a dapibus tellus.;
Lorem ipsum dolor sit amet, consectetur adipiscing elit.;
Aliquam interdum mauris volutpat nisl placerat, et facilisis neque ultrices.;

La solución es simple, como conocemos que el RS por defecto es el salto de línea basta con que al de salida OFS le antepongamos el punto y coma ;\n

:warning: ATENCIÓN: ¿Que hace ese 1?4


04. ¿Y una coma entre cada palabra?

$ awk '{$1=$1}1' OFS=',' lorem.dat
Lorem,ipsum,dolor,sit,amet,,consectetur,adipiscing,elit.
Maecenas,pellentesque,erat,vel,tortor,consectetur,condimentum.
Nunc,enim,orci,,euismod,id,nisi,eget,,interdum,cursus,ex.
Curabitur,a,dapibus,tellus.
Lorem,ipsum,dolor,sit,amet,,consectetur,adipiscing,elit.
Aliquam,interdum,mauris,volutpat,nisl,placerat,,et,facilisis,neque,ultrices.

Los más notable de este código es como se fuerza que awk reconstruya el registro completo, usando los valores actuales para el FS y OFS.

Para hacerlo usamos esta asignación inocua: $1 = $1


05. ¿Todo junto?

$ awk '{$1=$1}1' OFS=',' ORS=';\n' lorem.dat
Lorem,ipsum,dolor,sit,amet,,consectetur,adipiscing,elit.;
Maecenas,pellentesque,erat,vel,tortor,consectetur,condimentum.;
Nunc,enim,orci,,euismod,id,nisi,eget,,interdum,cursus,ex.;
Curabitur,a,dapibus,tellus.;
Lorem,ipsum,dolor,sit,amet,,consectetur,adipiscing,elit.;
Aliquam,interdum,mauris,volutpat,nisl,placerat,,et,facilisis,neque,ultrices.;

Tan simple como jugar con las dos variables de salida OFS y ORS.


06. Incluir los registros impares en un archivo y los pares en otro

Partimos de la solución:

$ awk 'NR%2{print >"impar.dat";next}{print >"par.dat"}' lorem.dat
$ cat par.dat
Maecenas pellentesque erat vel tortor consectetur condimentum.
Curabitur a dapibus tellus.
Aliquam interdum mauris volutpat nisl placerat, et facilisis.
$ cat impar.dat
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Nunc enim orci, euismod id nisi eget, interdum cursus ex.
Lorem ipsum dolor sit amet, consectetur adipiscing elit

El símbolo % es la función modulo que extrae el resto al dividir el número de registro o línea tratado entre 2:

$ awk '{print NR%2}' lorem.dat
1
0
1
0
1
0

Como sabemos en awk 1 es verdadero y 0 falso, en base a esta premisa redirigimos la salida según lo requerido.

Hay que prestar especial atención al comando next que fuerza que awk deje de forma inmediata de procesar ese registro y pase al siguiente. De esta forma evitamos un doble control de condición que sin next se hubiera escrito así:

awk  'NR % 2{print > "impar.dat"}
     !NR % 2{print > "par.dat"}' lorem.dat

07. Dado un fichero de password informa el campo del home del usuario en base al login

$ cat /etc/passwd
jd001:x:1032:666:Javier Diaz::/bin/rbash
ag002:x:8050:668:Alejandro Gonzalez::/bin/rbash
jp003:x:1000:666:Jose Perez::/bin/bash
ms004:x:8051:668:Maria Saenz::/bin/rbash
rc005:x:6550:668:Rosa Camacho::/bin/rbash

Supongamos que queremos componer el campo perdido anteponiendo la carpeta /home:

$ awk '$6="/home/"$1' FS=':' OFS=':' /etc/passwd
jd001:x:1032:666:Javier Diaz:/home/jd001:/bin/rbash
ag002:x:8050:668:Alejandro Gonzalez:/home/ag002:/bin/rbash
jp003:x:1000:666:Jose Perez:/home/jp003:/bin/bash
ms004:x:8051:668:Maria Saenz:/home/ms004:/bin/rbash
rc005:x:6550:668:Rosa Camacho:/home/rc005:/bin/rbash

Lo primero que tenemos que tener en cuenta es el carácter separador que divide los campos, tanto a la hora de tratar el stdin como el stdout. Esto se traduce en FS=':' y OFS=':'.

En segundo lugar identificamos la posición en la que se encuentra el campo void, en este caso es la número 6 y componemos su nuevo valor en base a la cadena requerida y el primero de los campos que contiene el login, el código final: $6="/home/"$1.

IMPORTANTE: No necesitamos print ya que el resultado de la asignación será true y el comportamiento por defecto de awk cuando algo es verdadero es mostrar el registro afectado.


08. Cambiar el orden de los campos de modo que el primero pase a ser el final

Código final:

$ awk -F\: '{last=$1;$1=$NF;$NF=last}1' FS=":" OFS=':' /etc/passwd
/bin/rbash:x:1032:666:Javier Diaz:/home/jd001:jd001
/bin/rbash:x:8050:668:Alejandro Gonzalez:/home/ag002:ag002
/bin/bash:x:1000:666:Jose Perez:/home/jp003:jp003
/bin/rbash:x:8051:668:Maria Saenz:/home/ms004:ms004
/bin/rbash:x:6550:668:Rosa Camacho:/home/rc005:rc005

Aquí jugamos con la variable intermedia last donde alojamos el valor del primer campo $1, después intercambiamos su valor con el del último campo $1=$NF, y finalmente asignamos last a $NF.


09. Hackeando traceroute

Partiendo de la salida del traceador:

$  traceroute -q 1 google.com 2>/dev/null
 1  hitronhub.home (192.168.1.1)  5.578 ms
 2  217.217.0.1.dyn.user.ono.com (217.217.0.1)  9.732 ms
 3  10.127.54.181 (10.127.54.181)  10.198 ms
 4  62.42.228.62.static.user.ono.com (62.42.228.62)  35.519 ms
 5  72.14.235.20 (72.14.235.20)  26.003 ms
 6  216.239.50.133 (216.239.50.133)  25.678 ms
 7  mad01s24-in-f14.1e100.net (216.58.211.238)  25.019 ms

Deseamos computar el total de micro segundos que tarda un paquete en todo el recorrido investigado.

$ traceroute -q 1 google.com 2>/dev/null|\
  awk '{total+=$(NF-1)}
       END{print "Total ms: "total}'
Total ms: 153.424

La acción se ejecuta para todos los registros ya que no hay ningún tipo de filtraje.

total+=$(NF-1): acumulamos en la variable total el valor contenido en el penúltimo campo $(NF-1).

En la regla END mostramos el valor final.


10. Procesos dependientes de un PID padre

En primer lugar obtenemos el PID de nuestra shell:

$ echo $$
51026

Lanzamos procesos sleep en segundo plano cuyo padre será nuestro PID anterior.

$ sleep 10 & sleep 15 & sleep 20 &
[1] 86751
[2] 86752
[3] 86753

En base al comando ps filtramos mediante awk por el tercer campo $3 que corresponde al PPID:

$ ps -ef|awk '$3==51026'
  501 86751 51026   0  7:57PM ttys001    0:00.00 sleep 10
  501 86752 51026   0  7:57PM ttys001    0:00.00 sleep 15
  501 86753 51026   0  7:57PM ttys001    0:00.00 sleep 20
    0 86754 51026   0  7:57PM ttys001    0:00.00 ps -ef
  501 86755 51026   0  7:57PM ttys001    0:00.00 awk $3==51026

Nuestro objetivo final es volcar al terminal solo los procesos sleep:

$ ps -ef|awk '$3==51026 && /slee[p]/ {print $2" -> "$5}'
86751 -> 7:57PM
86752 -> 7:57PM
86753 -> 7:57PM

Para ello necesitamos añadir al la condición inicial mediante && la búsqueda del patrón /slee[p]/ en el registro.

La acción asociada será imprimir el segundo campo que corresponde al PID del proceso y el quinto asociado al timestamp.


11. Como agregar datos a partir de una clave

Supongamos el siguiente fichero:

$ cat ips.dat
IP            BYTES
81.220.49.127 328
81.220.49.127 328
81.220.49.127 329
81.220.49.127 367
81.220.49.127 5302
81.226.10.238 328
81.227.128.93 84700

Nuestro objetivo es determinar cuantos bytes han sido tratados por IP.

$ awk 'NR>1{ips[$1]+=$2}
       END{for (ip in ips){print ip, ips[ip]}}' ips.dat
81.220.49.127 6654
81.227.128.93 84700
81.226.10.238 328

Tenemos bastante que explicar:

NR>1{ips[$1]+=$2}: Cuando el número de registro procesado es superior a 1 se ejecuta acción ips[$1]+=$2. EL NR>1 es necesario para esquivar la cabecera del archivo, es decir, la primera línea. ips es un array indexado por la ip (el campo $1) al que se le asigna la sumatoria del campo 2 $2.

Es importante tener en cuenta que awk cuando no encuentra una key en un array añade un nuevo elemento, pero si ocurre lo lee y puede manipular su valor como en este caso particular.

En la parte del END simplemente recorremos el array mostrando sus índices o keys y el valor contenido.

Este código se podría escribir de esta forma preservando el orden y omitiendo el uso de arrays aprovechando que las ips están ordenadas:

awk 'NR == 1{next}
    lip && lip != $1{print lip,sum;sum=0}
    {sum+=$2;lip=$1}
    END{print lip,sum}' ips.dat
81.220.49.127 6654
81.226.10.238 328
81.227.128.93 84700

NR == 1{next}: Omite la cabecera.

lip != $1 && lip != ""{print lip,sum;sum=0}: Usamos lip (last ip) como variable auxiliar y solo en el caso de que esta no sea nula lip y && sea distinta del la ip actual lip != $1 mostraremos la ip anterior lip y la sumatoria sum para después inicializarla: sum=0.

{sum+=$2;lip=$1}: Aquí llevamos a cabo el incremento de la sumatoria de bytes sum+=$2 y asignamos el campo de ip actual a lip.

En el bloque END mostraremos los resultados para la última ip así como la sumatoria final.

Este código preserva el orden pero aumenta la complejidad del programa.


12. Mostrar los registros entre dos patrones

Necesitamos extraer la líneas comprendidas entre OUTPUT y END.

$ cat pat.dat
test -3
test -2
test -1
OUTPUT
top 2
bottom 1
left 0
right 0
page 66
END
test 1
test 2
test 3

Este es un ejemplo clásico a la hora de ilustrar el funcionamiento del macheo de patrones y sus acciones asociadas al que ya le dedique un post (en inglés).

$ awk '/END/{flag=0}flag;/OUTPUT/{flag=1}' pat.dat
top 2
bottom 1
left 0
right 0
page 66

Como podemos ver como su funcionamiento se basa en asociar un valor para flag verdadero al encontrar el patrón de inicio y falso al encontrar el que cierra.

Para evitar un paso adicional es importante el orden de las acciones, si lo hacemos siguiendo la secuencia lógica:

$ awk '/OUTPUT/{flag=1}flag;/END/{flag=0}' pat.dat
OUTPUT
top 2
bottom 1
left 0
right 0
page 66
END

Las etiquetas se muestran en la salida ya que después de encontrar el patrón de inicio OUTPUT activamos el flag y la siguiente acción se realiza sobre el mismo registro, cosa que evitamos si esta activación la realizamos en el último paso de nuestro flujo.

Debemos evitar que flag sea verdadero cuando no deseemos mostrar los registro.


13. Convertir un campo en base a su contenido

Supongamos este fichero:

$ cat space.dat
10.80 kb
60.08 kb
35.40 kb
2.20 MB
1.10 MB
40.80 kb
3.15 MB
20.50 kb

Nuestro objetivo es determinar cuantas megas pesan nuestros registros.

$ awk '{total+= $1 / ($2=="kb" ? 1024: 1)}
       END{print total}'  space.dat
6.61365

Para poder entender su funcionamiento tenemos que tener claro como funciona el operador ternario al que ya dedique en su día un articulo completo (en inglés).

Sobre la variable total acumulamos el resultado de dividir el primer campo $1 entre el segundo que gracias a la magia de este operador equivaldrá a 1024 en caso de que su valor sea kb y a 1 si ya está en MBs.

Finalmente imprimimos el resultado total en el bloque END.


14. Agrupando registros en columnas

Nuestro fuente original:

$ cat group.dat
string1
string2
string3
string4
string5
string6
string8

Pretendemos agrupar los registros en bloques de tres columnas para obtener la siguiente salida:

string1 string2 string3
string4 string5 string6
string8

Parece complejo , pero resulta mucho más simple si entendemos como usar el Output Field Separator OFS:

$ awk 'ORS=NR%3?" ":RS; END{print "\n"}' group.dat
string1 string2 string3
string4 string5 string6
string8

Si establecemos el ORS a un carácter en blanco todo la salida se agrupa en una sola línea o registro:

$ awk 'ORS=" "; END{print "\n"}' group.dat
string1 string2 string3 string4 string5 string6 string7

ORS=NR%3?" ":RS: Finalmente usamos el operador ternario explicado anteriormente y evaluamos el modulo resultado de dividir el número de registro actual (teniendo en cuenta que el RS no se ha tocado por lo que sigue siendo \n) entre tres, si el resultado es true (distinto a cero) el ORS pasara a ser un espacio en blanco, en caso contrario se le asignará el valor del RS, es decir el salto de línea unix.


15. Procesando un fichero FASTA

En bioinformática, el formato FASTA es un formato de fichero informático basado en texto.

Supongamos el siguiente ejemplo:

$ cat fasta.dat
>header1
CGCTCTCTCCATCTCTCTACCCTCTCCCTCTCTCTCGGATAGCTAGCTCTTCTTCCTCCT
TCCTCCGTTTGGATCAGACGAGAGGGTATGTAGTGGTGCACCACGAGTTGGTGAAGC
>header2
GGT
>header3
TTATGAT

Nuestro objetivo es agregar los resultados de modo que obtengamos la longitud total de las secuencias de cada header y una sumarización total de la siguiente forma:

>header1
117
>header2
3
>header3
7
3 sequences, total length 127

awk es una herramienta perfecta para este tipo de reporting, en este ejemplo usaremos:

awk '/^>/ { if (seqlen) {
              print seqlen
              }
            print

            seqtotal+=seqlen
            seqlen=0
            seq+=1
            next
            }
    {
    seqlen += length($0)
    }
    END{print seqlen
        print seq" sequences, total length " seqtotal+seqlen
    }' fasta.dat

La primera acción esta asociada a a la detección de la cabecera /^>/ ya que los header son aquellos en los que el registro comienza con el carácter >

Cuando la variable seqlen tenga valor la mostraremos, en todo caso se volacará por el stdout la cabecera, se acumula la longitud total en seqtotal, el número de secuencias tratadas en seq y se inicializará la longitud de la secuencia actual seqlen, finalmente pasamos al siguiente registro con next.

Las segunda acción es acumular en seqlen la longitud de línea cuando no se trate de una cabecera.

En el bloque END mostramos la longitud de secuencia remanente y los totales.

Este ejemplo se basa en mostrar la longitud de secuencia del header anterior, al procesar el primer registro evidentemente no tiene valor y se omite, en el END se muestra la información disponible después de procesar la última línea.


16. Reporting complejo

Supongamos el siguiente fichero:

$ cat report.dat
       snaps1:          Counter:             4966
        Opens:          Counter:           357283

     Instance:     s.1.aps.userDatabase.mount275668.attributes

       snaps1:          Counter:                0
        Opens:          Counter:           357283

     Instance:     s.1.aps.userDatabase.test.attributes

       snaps1:          Counter:             5660
        Opens:          Counter:            37283

     Instance:     s.1.aps.userDatabase.mount275000.attributes

Nuestro objetivo es hacer un report en el se muestre la línea de snaps y su instancia asociada pero unicamente cuando el valor del primer counter sea superior a cero, en este caso buscaríamos:

snaps1: Counter: 4966
Instance: s.1.aps.userDatabase.mount275668.attributes
snaps1: Counter: 5660
Instance: s.1.aps.userDatabase.mount275000.attributes

Para conseguir este resultado nuevamente jugaremos con patrones y flags:

awk '{$1=$1}
     /snaps1/ && $NF>0{print;f=1}
     f &&  /Instance/ {print;f=0}'  report.dat

La primera acción {$1=$1} se ejecuta para todos los registros y como ya hemos visto sirve para reconstruir los registros, en este caso el truco nos permite convertir los separadores basados en múltiples espacios en blanco en uno solo ya que es el valor por defecto para el OFS.

Lo apreciamos mejor con un ejemplo:

$ awk '1' text.dat
one      two
three              four
$ awk '$1=$1' text.dat
one two
three four

La segunda acción se dispara cuando encontramos el patrón /snaps1/ y (&&) el último campo es mayor que 0 $NF>0. Mostraremos el registro con print y daremos un valor verdadero al flag f=1.

Por último cuando el flag es verdadero y encontramos el patrón de instancia f && /Instance/ imprimimos la línea y desactivaremos la variable: print;f=0.


17. JOIN entre ficheros

Partimos de dos ficheros.

$ cat join1.dat
3.5 22
5. 23
4.2 42
4.5 44
$ cat join2.dat
3.5
3.7
5.
6.5

Necesitamos obtener las registros del primero cuyas columnas están presentes en join2.dat. Es decir:

3.5 22
5. 23

Por supuesto que podríamos usar la utilidad unix join ordenado el primer fichero:

$ join <(sort join1.dat) join2.dat
3.5 22
5. 23

Veamos como se puede hacer con awk:

$ awk 'NR==FNR{a[$1];next}
       $1 in a'  join2.dat join1.dat

Como siempre descompongamos por acciones.

Cuando el número de registro procesado equivalga al número de registro del fichero actual, es decir NR==NFR significará que estamos tratando el primer archivo.

La acción asociada será incorporar al array a un elemento nulo indexado el contenido del primer campo $1 , es decir a[$1] inmediatamente después pasamos a procesar el siguiente registro mediante next.

La segunda acción se disparará de forma implícita cuando el NR sea distinto al FNR, es decir, cuando estemos procesando el segundo fichero, le aplicaremos el statement $1 in a que será true cuando el primer campo del fichero join1.dat este presente en el array formado por los campos del join2.dat.

Como ya sabemos, cuando algo es verdadero en awk por defecto se muestra el registro afectado por la condición por lo que obtenemos el resultado esperado.


18. Cruzando passwd y group

Supongamos estos dos clásicos unix:

$ cat /etc/group
dba:x:001:
netadmin:x:002:
$ cat /etc/passwd
jd001:x:1032:001:Javier Diaz:/home/jd001:/bin/rbash
ag002:x:8050:002:Alejandro Gonzalez:/home/ag002:/bin/rbash
jp003:x:1000:001:Jose Perez:/home/jp003:/bin/bash
ms004:x:8051:002:Maria Saenz:/home/ms004:/bin/rbash
rc005:x:6550:002:Rosa Camacho:/home/rc005:/bin/rbash

Nuestro objetivo es obtener un report login:nombre_grupo:

d001:dba
ag002:netadmin
jp003:dba
ms004:netadmin
rc005:netadmin

Para ello volveremos a usar las técnicas de trabajo con varios ficheros vistas en el ejemplo anterior:

$ awk -F\: 'NR==FNR{g[$3]=$1;next}
            $4 in g{print $1""FS""g[$4]}' /etc/group /etc/passwd

Para procesar /etc/group volvemos a usar la comparación NR==FNR para disparar la primera acción que almacenará en el array g bajo el index correspondiente al ID del grupo $3 su nombre $1, es decir g[$3]=$1. Posteriormente rompemos cualquier procesamiento posterior del registro con next.

La siguiente acción afectará a todos los registros del archivo /etc/passwd, cuando el cuarto campo $4 (que corresponde al ID del grupo del usuario) este presente en el array $4 in g mostraremos el login $1 y el valor asociado al elemento del array referido por la ID g[$4]. En definitiva: print $1""FS""g[$4]


19. Conexiones por usuario a un servidor

Partimos de la salida del comando users, ejemplo:

$ users
negan rick bart klashxx klashxx ironman ironman ironman

Necesitamos saber cuantos logeos tenemos por usuario

$ users|awk '{a[$1]++}END{for (i in a){print i,a[i]}}' RS=' +'
rick 1
bart 1
ironman 3
negan 1
klashxx 2

En este código la acción se ejecuta para todos los registros devueltos por el ejecutable ya que no existe ninguna filtro o patrón a procesar.

a[$1]++: Inicializa como 1 o suma un elemento sobre la key de usuario $1 del array a. En awk cuando un valor no esta iniciado se considera cero en un contexto numérico.

En el bloque END recorremos el array mostrando la key y su valor asociado.


20. Obteniendo la media total proporcionada por el comando uptime

Veamos la salida típica de esta utilidad:

$ uptime
 11:08:51 up 121 days, 13:09, 10 users,  load average: 9.12, 14.85, 20.84

¿Como podemos extraer la media total de las tres mediadas del load average?

$ uptime |awk '{printf "Media de carga: %0.2f\n", 
                ($(NF-2)+$(NF-1)+$(NF))/3 }' FS='(:|,) +'
Media de carga: 14.94

Aquí entra en juego una nueva técnica, usamos como separador de campos FS una expresión regular FS='(:|,) +'.

Le estamos indicando al programa que el separador pueden ser tanto los dos puntos : como las comas , seguidos por cero o más espacios.

Después simplemente nos quedamos con los tres últimos campos en base al NF, realizamos la operación aritmética y mostramos el resultado usando la mascara más idónea para printf.


:warning: Disclaimer 2 :warning:

Si has llegado hasta aquí ¡gracias por el interés!

En mi opinión, awk es un lenguaje infravalorado y que merece mucho mas cariño :heart:.

Si además te has quedado con ganas de más házmelo saber en los comentarios y considerare una segunda parte para terminar de matarte de aburrimiento.

Happy coding!


  1. A Guide to Unix Shell Quoting

  2. Wikipedia sobre pipelines

  3. En ocasiones es conveniente forzar que awk reconstruya el registro completo, usando los valores actuales para el FS y OFS.

    Para hacerlo usamos esta asignación inocua: $1 = $1 

  4. La respuesta rápida, simplemente es un atajo para evitar usar la función print.

    En awk cuando se cumple una condición la acción por defecto es imprimir la línea actual en el input.

    $ echo "test" |awk '1'

    Lo que es equivalente a:

    echo "test"|awk '1==1'

    echo "test"|awk '{if (1==1){print}}'

    El motivo es que 1 siempre va a ser verdadero

Pipelines en Go

Traducción libre al español de post original Go Concurrency Pipelines

Un pipeline se podría definir como una serie de etapas de proceso conectadas por channels (canales).

Cada una de estas fases está conformada por un grupo de goroutines ejecutando una misma función:

  • Reciben valores a partir de los canales inbound o de entrada.

  • Ejecutan algún tipo de manipulación sobre esos datos.

  • Devuelven los valores a través de los canales de salida o outbound.

Las etapas están conectadas a través de un número arbitrario de canales de entrada y salida, excepto la primera y última que solo tendrán de salida y entrada respectivamente.

Ejemplo, números cuadrados

En una primera fase gen sería una función que emitiría los enteros recibidos en array.

Se ocupa de arrancar una goroutine, enviar los valores por el canal de salida (return) y cerrarlo una vez se haya completado el envío.

func gen(nums ...int) <-chan int {
    out := make(chan int)

    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

La segunda fase sq se encarga de recibir los enteros emitidos por la función gen y devolverlos por otro canal elevados al cuadrado, sin olvidar cerrar el channel al concluir el procesamiento.

func sq(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

En el main configuramos el pipeline y se ejecuta la fase final. No es necesario cerrar ya que solo controla el inbound.

func main() {
    // Set up the pipeline.
    c := gen(2, 3)
    out := sq(c)

    // Consume the output.
    fmt.Println(<-out) // 4
    fmt.Println(<-out) // 9
}

Como sq usa los mismos tipos para los canales de entrada y salida, podemos reusarlos y consumirlos mediante range al igual que se hizo en anteriores etapas.

func main() {
    // Set up the pipeline and consume the output.
    for n := range sq(sq(gen(2, 3))) {
        fmt.Println(n) // 16 then 81
    }
}

Fan-Out, Fan-in

Se pueden levantar múltiples funciones que se alimenten del mismo canal inbound.

A esto se conoce como Fan-out y permite paralelizar de forma simple el trabajo de los workers o consumidores.

También se puede usar una única función que lea múltiples canales inboud para agregar los resultados.

Para esta labor se usa la técnica del multiplexado de canales en uno solo que se cierra cuando todos los input lo hacen. A esto se conoce como Fan-in.

Podemos cambiar el pipeline para ejecutar dos instancias de sq, leerán del mismo canal de entrada necesitando una nueva función merge que se encargará del Fan-in de los resultados.

func main() {
    in := gen(2, 3)

    // Distribute the sq work across two goroutines that both read from in.
    c1 := sq(in)
    c2 := sq(in)

    // Consume the merged output from c1 and c2.
    for n := range merge(c1, c2) {
        fmt.Println(n) // 4 then 9, or 9 then 4
    }
}

merge convierte un array de canales en único, arrancando una goroutine para cada uno de los canales de entrada que copia los valores un único canal de salida.

Una vez arrancados todos los canales de salida merge lanza una ruina más para cerrar el canal outbound una vez completados todos los envíos.

ATENCIÓN: enviar sobre un canal cerrado produce panic afortunadamente sync.WaitGroup nos facilita la tarea de sincronización.

func merge(cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Start an output goroutine for each input channel in cs.  output
    // copies values from c to out until c is closed, then calls wg.Done.
    output := func(c <-chan int) {
        for n := range c {
            out <- n
        }
        wg.Done()
    }
    wg.Add(len(cs))
    for _, c := range cs {
        go output(c)
    }

    // Start a goroutine to close out once all the output goroutines are
    // done.  This must start after the wg.Add call.
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}
Resumiendo
  • Cada stage cierra sus canales de salida al terminar los envíos.
  • Cada stage se mantiene escuchando, recibiendo valores para los canales de entrada hasta que estos son cerrados.

Este patrón permite que cada recibidor pueda componerse como un bucle range y asegura que todas las goroutines terminen una vez procesados todos los valores.

Pero … en la vida real los recibidores no siempre obtienen todos los valores de entrada.

A veces, por diseño, el recibidor puede que solo necesite un grupo de valores para continuar progresando.

Más frecuentemente, terminan antes de tiempo al recibir errores en una fase temprana.

En cualquiera de estos casos el recibidor no debe esperar a que lleguen todos los valores restantes, necesitaremos que los productores dejen de generar valores que ya no son necesarios en fases posteriores.

En nuestro ejemplo si una de las fases falla al consumir los canales de entrada, las goroutines que estén intentando enviar a ese stage se bloquearan indefinidamente.

// Consume the first value from output.
out := merge(c1, c2)
fmt.Println(<-out) // 4 or 9
return
// Since we didn't receive the second value from out,
// one of the output goroutines is hung attempting to send it.

Una de las formas de evitar este problema es establecer un buffer para los canales inbound.

Por ejemplo, podemos usarlo en la función gen y evitar crear una nueva `goroutine.

func gen(nums ...int) <-chan int {
    out := make(chan int, len(nums))
    for _, n := range nums {
        out <- n
    }
    close(out)
    return out
}

Del mismo modo podemos tratar el merge.

func merge(cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int, 1) // enough space for the unread inputs
    // ... the rest is unchanged ...

Aunque esto evita el problema de los canales bloqueados se puede considerar una mala praxis.

La elección del tamaño de buffer depende de conocer de antemano el número de valores que va a recibir merge y el que consumirán las fases anteriores.

Si pasamos un valor adicional a gen o si los recibidores leen un menor número de valores tendrá como consecuencia un nuevo bloqueo.

Cancelación explicita

Cuando main decide que debe terminar sin recibir todos los valores de salida, debe comunicarlo a todos las rutinas upstream, para esta labor usa el canal done.

func main() {
    in := gen(2, 3)

    // Distribute the sq work across two goroutines that both read from in.
    c1 := sq(in)
    c2 := sq(in)

    // Consume the first value from output.
    done := make(chan struct{}, 2)
    out := merge(done, c1, c2)
    fmt.Println(<-out) // 4 or 9

    // Tell the remaining senders we're leaving.
    done <- struct{}{}
    done <- struct{}{}
}

Las rutinas de envío cambian su operación send por una sentencia select que procederá en función de que procese un mensaje de salida o que reciba un done.

done es una estructura vacía porque realmente no importa su contenido, simplemente es un evento al recibir que indica que el send en el productor debe ser abandonado.

Las rutinas productoras continúan escaneando su canal de entrada c por lo que no se bloquean los streams de envío.

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Start an output goroutine for each input channel in cs.  output
    // copies values from c to out until c is closed or it receives a value
    // from done, then output calls wg.Done.
    output := func(c <-chan int) {
        for n := range c {
            select {
            case out <- n:
            case <-done:
            }
        }
        wg.Done()
    }
    // ... the rest is unchanged ...

Este planteamiento deriva en otro problema, cada recibidor necesita conocer el número de potenciales bloqueadores productores y coordinar el envío a los senders de un retorno temprano.

Nuestro objetivo será: ordenar a un número indeterminado e ilimitado de rutinas que paren la producción de mensajes.

Podemos conseguirlo cerrando un canal, porque una operación de recepción sobre un canal cerrado se ejecutará de forma inmediata devolviendo el valor cero del tipo.

Esto significa que el main puede desbloquear todos los senders simplemente cerrando el canal done.

Esta actuación se traduce efectivamente en una señal broadcast sobre todos los senders. Todos los stages en nuestro pipeline deben estar preparadas para aceptar done como parámetro y lanzar el close vía defer, de tal forma que cualquier return desde el `main de la orden de salida a todas las fases del pipeline.

func main() {
    // Set up a done channel that's shared by the whole pipeline,
    // and close that channel when this pipeline exits, as a signal
    // for all the goroutines we started to exit.
    done := make(chan struct{})
    defer close(done)

    in := gen(done, 2, 3)

    // Distribute the sq work across two goroutines that both read from in.
    c1 := sq(done, in)
    c2 := sq(done, in)

    // Consume the first value from output.
    out := merge(done, c1, c2)
    fmt.Println(<-out) // 4 or 9

    // done will be closed by the deferred call.
}

Ahora la rutina de salida del merge puede escapar sin consumir todo el canal de entrada ya que sabe que el productor sq dejará de enviar cuando cierre el canal done.

La llamada a wg.Done es asegurada por defer.

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Start an output goroutine for each input channel in cs.  output
    // copies values from c to out until c or done is closed, then calls
    // wg.Done.
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            select {
            case out <- n:
            case <-done:
                return
            }
        }
    }
    // ... the rest is unchanged ...

Del mismo modo sq puede retornar tan pronto como se cierre `done.

func sq(done <-chan struct{}, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case out <- n * n:
            case <-done:
                return
            }
        }
    }()
    return out
}
Resumiendo
  • Los canales de salida deben cerrase tan pronto se finalicen las operaciones de envío.
  • Los canales de entrada continua recibiendo hasta que son cerrados o desbloqueados.

Los pipelines desbloquean los productores asegurándose de que existe espacio suficiente en el buffer u ordenándolo directamente al abandonar un canal.

Ejemplo MD5

MD5 es un algoritmo de mensajes útil para checksum de ficheros.

Ejemplo de salida de md5sum.

% md5sum *.go
d47c2bbc28298ca9befdfbc5d3aa4e65  bounded.go
ee869afd31f83cbb2d10ee81b2b831dc  parallel.go
b88175e65fdcbc01ac08aaf1fd9b5e96  serial.go

Lo imitaremos usando un directorio como argumento para mostrar por stdout los valores de checksum para cada uno de los ficheros que contiene.

El main contendrá una función helper MD5All que retornará un map que asocie el path y el valor md5, finalmente ordena y muestra los resultados.

func main() {
    // Calculate the MD5 sum of all files under the specified directory,
    // then print the results sorted by path name.
    m, err := MD5All(os.Args[1])
    if err != nil {
        fmt.Println(err)
        return
    }
    var paths []string
    for path := range m {
        paths = append(paths, path)
    }
    sort.Strings(paths)
    for _, path := range paths {
        fmt.Printf("%x  %s\n", m[path], path)
    }
}

El MD5All es el centro de nuestro análisis. En serial.go la implementación no usa concurrencia se limita a leer y calcular el checksum de cada fichero al recorrer el árbol de ficheros.

// MD5All reads all the files in the file tree rooted at root and returns a map
// from file path to the MD5 sum of the file's contents.  If the directory walk
// fails or any read operation fails, MD5All returns an error.
func MD5All(root string) (map[string][md5.Size]byte, error) {
    m := make(map[string][md5.Size]byte)
    err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        if !info.Mode().IsRegular() {
            return nil
        }
        data, err := ioutil.ReadFile(path)
        if err != nil {
            return err
        }
        m[path] = md5.Sum(data)
        return nil
    })
    if err != nil {
        return nil, err
    }
    return m, nil
}

Procesamiento paralelo

En parallel.go dividimos MD5All en un pipeline en dos fases.

En la primera, sumFiles se encargará de recorrer el árbol y leer cada fichero regular mediante una goroutine, finalmente envía lo producido a un canal tipo result.

type result struct {
    path string
    sum  [md5.Size]byte
    err  error
}

sumFiles devuelve dos canales, uno para results y otro para los posibles errores devueltos por filepah.Walk.

walk arranca una nueva goroutine para cada uno de los archivos, y comprueba el canal done, si este se cierra sumFiles termina inmediatamente.

func sumFiles(done <-chan struct{}, root string) (<-chan result, <-chan error) {
    // For each regular file, start a goroutine that sums the file and sends
    // the result on c.  Send the result of the walk on errc.
    c := make(chan result)
    errc := make(chan error, 1)
    go func() {
        var wg sync.WaitGroup
        err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if err != nil {
                return err
            }
            if !info.Mode().IsRegular() {
                return nil
            }
            wg.Add(1)
            go func() {
                data, err := ioutil.ReadFile(path)
                select {
                case c <- result{path, md5.Sum(data), err}:
                case <-done:
                }
                wg.Done()
            }()
            // Abort the walk if done is closed.
            select {
            case <-done:
                return errors.New("walk canceled")
            default:
                return nil
            }
        })
        // Walk has returned, so all calls to wg.Add are done.  Start a
        // goroutine to close c once all the sends are done.
        go func() {
            wg.Wait()
            close(c)
        }()
        // No select needed here, since errc is buffered.
        errc <- err
    }()
    return c, errc
}

MD5All recibe los valores, en caso de error finaliza y realiza el close vía defer.

func MD5All(root string) (map[string][md5.Size]byte, error) {
    // MD5All closes the done channel when it returns; it may do so before
    // receiving all the values from c and errc.
    done := make(chan struct{})
    defer close(done)

    c, errc := sumFiles(done, root)

    m := make(map[string][md5.Size]byte)
    for r := range c {
        if r.err != nil {
            return nil, r.err
        }
        m[r.path] = r.sum
    }
    if err := <-errc; err != nil {
        return nil, err
    }
    return m, nil
}

Paralelismo limitado.

El ejemplo anterior arrancábamos una rutina por cada fichero. Esto puede provocar el agotamiento de los recursos del sistema.

Podemos limitar este uso estableciendo un número máximo de ficheros a leer en paralelo.

En bounded.go se establecen un número fijo de goroutines para leer los ficheros.

Nuestro pipeline se compondrá de tres etapas.

  • Recorrer el árbol.
  • Leer los ficheros y obtener su checksum.
  • Recoger los resultados.

En la primera etapa walkFiles retorna los path a los archivos.

func walkFiles(done <-chan struct{}, root string) (<-chan string, <-chan error) {
    paths := make(chan string)
    errc := make(chan error, 1)
    go func() {
        // Close the paths channel after Walk returns.
        defer close(paths)
        // No select needed for this send, since errc is buffered.
        errc <- filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if err != nil {
                return err
            }
            if !info.Mode().IsRegular() {
                return nil
            }
            select {
            case paths <- path:
            case <-done:
                return errors.New("walk canceled")
            }
            return nil
        })
    }()
    return paths, errc
}

En la fase intermedia se arranca un número fijo de rutinas que reciben los paths a los ficheros y envían los resultados al canal c.

func digester(done <-chan struct{}, paths <-chan string, c chan<- result) {
    for path := range paths {
        data, err := ioutil.ReadFile(path)
        select {
        case c <- result{path, md5.Sum(data), err}:
        case <-done:
            return
        }
    }
}

Al contrario que en ejemplos anteriores, digester no cierra su canal de entrada, ya que puede ser usado por múltiples goroutines en un canal común. MD5All coordina todos canales para cerrarlos cuando finalicen todos los digesters.

 // Start a fixed number of goroutines to read and digest files.
c := make(chan result)
var wg sync.WaitGroup
const numDigesters = 20
wg.Add(numDigesters)
for i := 0; i < numDigesters; i++ {
    go func() {
        digester(done, paths, c)
        wg.Done()
    }()
}
go func() {
    wg.Wait()
    close(c)
}()

Podríamos optar por que cada uno de los digester crearan y retornaran su propio canal pero esto obligaría a crear goroutines adicionales para poder realizar el Fan-in de los resultados.

La última fase recibe todos los result de c y chequea el canal de error. Esta comprobación no puede realizarse previamente, antes de este punto walkFiles podría bloquear enviando valores al downstream.

m := make(map[string][md5.Size]byte)
for r := range c {
    if r.err != nil {
        return nil, r.err
    }
    m[r.path] = r.sum
}
// Check whether the Walk failed.
if err := <-errc; err != nil {
    return nil, err
}
return m, nil

Where is my Broken Link

Post first published in nixtip

This task bugs me quite often, there many methods to perform this job but want to share the options I consider safer.

Having this scenario:

$ touch a b c
$ ln -s a a.link
$ ln -s b b.link
$ ln -s c c.link
$ rm b
$ ll
total 0
-rw-r--r--   1 klashxx   klashxx   0 Feb 1 09:45 a
lrwxr-xr-x   1 klashxx   klashxx   1 Feb 1 09:45 a.link -> a
lrwxr-xr-x   1 klashxx   klashxx   1 Feb 1 09:45 b.link -> b
-rw-r--r--   1 klashxx   klashxx   0 Feb 1 09:45 c
lrwxr-xr-x   1 klashxx   klashxx   1 Feb 1 09:45 c.link -> c

Find

find . -type l -exec test ! -e {} \; -print

-type l True if is a symbolic link file type.

-exec test ! -e {} Test if the file where the link points DOES NOT exists.

-print Show the broken link

Outputs

./b.link

Perl + bash

for link in *.link; do
  perl -se 'exit 5 unless (-e readlink($link));' -- -link=$link
  [[ $? -eq 5 ]] && echo "broken link: $link"
done

Outputs

broken link: b.link

Let’s explain the mini Perl program that makes the trick.

-s

enables rudimentary switch parsing for switches on the command line after the program name but before any filename arguments (or before an argument of –). Any switch found there is removed from @ARGV and sets the corresponding variable in the Perl program. The following program prints “1” if the program is invoked with a -xyz switch, and “abc” if it is invoked with -xyz=abc.

-e commandline

may be used to enter one line of program. If -e is given, Perl will not look for a filename in the >argument list. Multiple -e commands may be given to build up a multi-line script. Make sure to use >semicolons where you would in a normal program.

So… we use –s to pick link parameter and –e to execute the program.

Program

exit 5 unless (-e readlink($link)) returns where is the symbolic link pointing.

Arguments

-- marks the end of options and disables further option processing. Any arguments after the —- are treated as filenames and arguments (bash man).

-link=$link pass link variable to Perl program (see –s flag)

-e test if the file exists.

Python Date format validator

Post first published in nixtip

Simplicity is always best and Python makes this task insanely easy.

We just only need to take advantage of the strptime function of the time module.

As usually in Python, the code is pretty self-explanatory:

#!/usr/bin/python
"""date_validator.py"""
import time

def check_format(datec):
    # checks YYYYMMDD / YYYY-MM-DD / DDMMYYYY and MMDDYYYY formats

    format_ok = False
    for mask in ['%Y%m%d','%Y-%m-%d','%d%m%Y','%m%d%Y']:
    try:
        time.strptime(datec, mask)
        format_ok = True
        break
    except ValueError:
        pass

    if format_ok:
        print "Correct date !:%12s mask:%s" % (datec,mask)
    else:
        print "KO: %s" % datec
    return None

def main():
    check_format('11082011')
    check_format('12312010')
    check_format('13312010')
    check_format('20110811')
    check_format('40118841')
    check_format('2012-02-29')
    check_format('20110229')

if __name__=="__main__":
    main()
./date_validator.py
Correct date !:    11082011 mask:%d%m%Y
Correct date !:    12312010 mask:%m%d%Y
KO: 13312010
Correct date !:    20110811 mask:%Y%m%d
KO: 40118841
Correct date !:  2012-02-29 mask:%Y-%m-%d
Correct date !:    20110229 mask:%d%m%Y

If we use the interpreter it couldn’t be more clear.

Choose a good pair of date + mask and the result will be fine:

>>> import time
>>> datec='20110811'
>>> time.strptime(datec,'%Y%m%d')
time.struct_time(tm_year=2011, tm_mon=8, tm_mday=11, tm_hour=0, tm_min=0, tm_sec=0, tm_wday=3, tm_yday=223, tm_isdst=-1)

Otherwise:

>>> time.strptime(datec,'%m%d%Y')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib64/python2.6/_strptime.py", line 454, in _strptime_time
return _strptime(data_string, format)[0]
File "/usr/lib64/python2.6/_strptime.py", line 328, in _strptime
data_string[found.end():])
ValueError: unconverted data remains: 1

PLAIN TEXT the language of *NIX

Post first published in nixtip

Let’s face it, one of the main task of the IT administrator could possibly be text processing.

Why? Because text is everywhere, computer systems speak text in three different languages STDOUT, STDERR and STDIN.

We need to develop our skills to create log files miners, to adapt the ugly output of a program to meet our needs, etc…

Fortunately *nix offer may options to get our goals, so we have to try many tools until we find the one we get comfortable with.

Usually there’s nothing wrong with that, nowadays, most times, the power of the machines allow us to use the tool we want focusing on the results.

But… what happens when we have to process a 4 GBs file, or many files on production systems ?

Let’s illustrate this with an example.

You can use this shell script to create the sample text file:

$ i=1
$ while (( i<=10000000 )); do echo "Line: $i"; (( i += 1 )); done>dummy.txt

NOTE: the weight of the should be around 130 MB.

Now we have a 10 million lines flat text file.

Our task is simple, extract the line number 5000000

Let’s bench a bunch of common ways:

$ time -p sed -n '5000000p' dummy.txt
Line: 5000000
real 1.12
user 1.06
sys 0.06
$ time -p awk 'NR==5000000{print;exit}' dummy.txt
Line: 5000000
real 1.09
user 1.05
sys 0.03
$ time -p perl -ne '$. == 5000000 && {print and exit}' dummy.txt
Line: 5000000
real 1.78
user 1.73
sys 0.04
$ time -p head -5000000 dummy.txt >dummy.txt.2
real 0.28
user 0.07
sys 0.20

$ time -p tail -1 dummy.txt.2
Line: 5000000
real 0.00
user 0.00
sys 0.00
$ time -p (head -5000000 dummy.txt |tail -1)
Line: 5000000
real 0.16
user 0.21
sys 0.10

The processing times are quite similar, but if you don’t use the right logic …

$ time -p perl -ne '$. == 5000000 && print' dummy.txt
Line: 5000000
real 3.54
user 3.48
sys 0.06
$ time -p awk 'NR==5000000' dummy.txt
Line: 5000000
real 2.19
user 2.14
sys 0.05

We get a double time here! (no exit after found the line)

Conclusion: Don’t care too much about the tool, care about your programming skills or be ready to waste precious computing time.

© Juan Diego Godoy Robles