¿Por qué usar un bucle de shell para procesar texto se considera una mala práctica?

196

¿Usar un ciclo while para procesar texto generalmente se considera una mala práctica en shells POSIX?

Como señaló Stéphane Chazelas , algunas de las razones para no usar shell loop son conceptuales , confiabilidad , legibilidad , rendimiento y seguridad .

Esta respuesta explica los aspectos de confiabilidad y legibilidad :

while IFS= read -r line <&3; do
  printf '%s\n' "$line"
done 3< "$InputFile"

Para el rendimiento , el whilebucle y la lectura son tremendamente lentos cuando se lee desde un archivo o una tubería, porque el shell de lectura incorporado lee un carácter a la vez.

¿Qué hay de los aspectos conceptuales y de seguridad ?

Cuonglm
fuente
Relacionado (el otro lado de la moneda): ¿Cómo yesescribe para archivar tan rápido?
Comodín el
1
El shell de lectura incorporado no lee un solo carácter a la vez, sino que lee una sola línea a la vez. wiki.bash-hackers.org/commands/builtin/read
A.Danischewski
@ A.Danischewski: Depende de tu caparazón. En bash, lee un tamaño de búfer a la vez, intente, dashpor ejemplo. Ver también unix.stackexchange.com/q/209123/38906
cuonglm

Respuestas:

256

Sí, vemos varias cosas como:

while read line; do
  echo $line | cut -c3
done

O peor:

for line in `cat file`; do
  foo=`echo $line | awk '{print $2}'`
  echo whatever $foo
done

(No te rías, he visto muchos de esos).

Generalmente de principiantes de scripts de shell Esas son traducciones literales ingenuas de lo que harías en lenguajes imperativos como C o python, pero no es así como haces las cosas en shells, y esos ejemplos son muy ineficientes, completamente poco confiables (potencialmente conducen a problemas de seguridad), y si alguna vez logras Para corregir la mayoría de los errores, su código se vuelve ilegible.

Conceptualmente

En C o en la mayoría de los otros lenguajes, los bloques de construcción están solo un nivel por encima de las instrucciones de la computadora. Le dice a su procesador qué hacer y luego qué hacer a continuación. Toma su procesador de la mano y lo microgestiona: abre ese archivo, lee tantos bytes, hace esto, lo hace con él.

Los shells son un lenguaje de nivel superior. Se puede decir que ni siquiera es un idioma. Están ante todos los intérpretes de línea de comandos. El trabajo lo realizan los comandos que ejecuta y el shell solo está destinado a orquestarlos.

Una de las mejores cosas que introdujo Unix fue la tubería y las secuencias stdin / stdout / stderr predeterminadas que todos los comandos manejan de manera predeterminada.

En 45 años, no hemos encontrado una API mejor que esa para aprovechar el poder de los comandos y hacer que cooperen en una tarea. Esa es probablemente la razón principal por la cual las personas todavía usan conchas hoy en día.

Tiene una herramienta de corte y una herramienta de transliteración, y simplemente puede hacer:

cut -c4-5 < in | tr a b > out

El shell solo está haciendo la plomería (abre los archivos, configura las tuberías, invoca los comandos) y cuando todo está listo, simplemente fluye sin que el shell haga nada. Las herramientas hacen su trabajo al mismo tiempo, de manera eficiente a su propio ritmo con suficiente almacenamiento en búfer para que ninguno bloquee al otro, es simplemente hermoso y, sin embargo, muy simple.

Sin embargo, invocar una herramienta tiene un costo (y lo desarrollaremos en el punto de rendimiento). Esas herramientas pueden escribirse con miles de instrucciones en C. Debe crearse un proceso, la herramienta debe cargarse, inicializarse, luego limpiarse, destruirse el proceso y esperar.

Invocar cutes como abrir el cajón de la cocina, tomar el cuchillo, usarlo, lavarlo, secarlo y volver a colocarlo en el cajón. Cuando tu lo hagas:

while read line; do
  echo $line | cut -c3
done < file

Es como para cada línea del archivo, obtener la readherramienta del cajón de la cocina (una muy torpe porque no ha sido diseñada para eso ), leer una línea, lavar la herramienta de lectura, volver a colocarla en el cajón. Luego programe una reunión para la herramienta echoy cut, sáquelos del cajón, invoquelos, lávelos, séquelos, vuelva a colocarlos en el cajón, etc.

Algunas de esas herramientas ( ready echo) están construidas en la mayoría de los shells, pero eso apenas hace una diferencia aquí desde entonces echoy cutaún deben ejecutarse en procesos separados.

Es como cortar una cebolla pero lavar el cuchillo y volver a colocarlo en el cajón de la cocina entre cada rebanada.

Aquí, la forma obvia es sacar su cutherramienta del cajón, cortar toda la cebolla y volver a colocarla en el cajón una vez que haya terminado todo el trabajo.

IOW, en shells, especialmente para procesar texto, invocas la menor cantidad de utilidades posible y haces que cooperen en la tarea, no ejecutan miles de herramientas en secuencia esperando que cada una comience, se ejecute y se limpie antes de ejecutar la siguiente.

Lectura adicional en la buena respuesta de Bruce . Las herramientas internas de procesamiento de texto de bajo nivel en shells (excepto quizás para zsh) son limitadas, engorrosas y, en general, no son aptas para el procesamiento de texto general.

Actuación

Como se dijo anteriormente, ejecutar un comando tiene un costo. Un costo enorme si ese comando no está integrado, pero incluso si están integrados, el costo es grande.

Y los shells no han sido diseñados para ejecutarse así, no pretenden ser lenguajes de programación eficaces. No lo son, solo son intérpretes de línea de comandos. Entonces, se ha hecho poca optimización en este frente.

Además, los shells ejecutan comandos en procesos separados. Esos bloques de construcción no comparten una memoria o estado común. Cuando haces una fgets()o fputs()en C, esa es una función en stdio. stdio mantiene buffers internos para entrada y salida para todas las funciones stdio, para evitar hacer costosas llamadas al sistema con demasiada frecuencia.

Los correspondientes incluso utilidades de shell incorporadas ( read, echo, printf) pueden no hacerlo. readestá destinado a leer una línea. Si se lee más allá del carácter de nueva línea, eso significa que el siguiente comando que ejecute lo perderá. Por lo tanto, readtiene que leer la entrada un byte a la vez (algunas implementaciones tienen una optimización si la entrada es un archivo normal en el sentido de que leen fragmentos y buscan, pero eso solo funciona para archivos regulares y, bashpor ejemplo, solo lee fragmentos de 128 bytes, lo cual es todavía mucho menos de lo que harán las utilidades de texto).

Lo mismo en el lado de la salida, echono puede simplemente almacenar su salida en el búfer, sino que debe enviarla de inmediato porque el siguiente comando que ejecute no compartirá ese búfer.

Obviamente, ejecutar comandos secuencialmente significa que debe esperarlos, es un pequeño baile de planificación que le da el control desde el shell y las herramientas y viceversa. Eso también significa (en lugar de usar instancias de herramientas de larga ejecución en una tubería) que no puede aprovechar varios procesadores al mismo tiempo cuando estén disponibles.

Entre ese while readciclo y el (supuestamente) equivalente cut -c3 < file, en mi prueba rápida, hay una relación de tiempo de CPU de alrededor de 40000 en mis pruebas (un segundo versus medio día). Pero incluso si usa solo cartuchos incorporados:

while read line; do
  echo ${line:2:1}
done

(aquí con bash), eso sigue siendo alrededor de 1: 600 (un segundo frente a 10 minutos).

Fiabilidad / legibilidad

Es muy difícil obtener ese código correcto. Los ejemplos que di se ven con demasiada frecuencia en la naturaleza, pero tienen muchos errores.

reades una herramienta útil que puede hacer muchas cosas diferentes. Puede leer la entrada del usuario, dividirla en palabras para almacenar en diferentes variables. read lineno no leer una línea de entrada, o tal vez se lee una línea de una manera muy especial. En realidad, lee palabras de la entrada de esas palabras separadas por $IFSy donde la barra invertida se puede utilizar para escapar de los separadores o el carácter de nueva línea.

Con el valor predeterminado de $IFS, en una entrada como:

   foo\/bar \
baz
biz

read linese almacenará "foo/bar baz"en $line, no " foo\/bar \"como es de esperar.

Para leer una línea, en realidad necesita:

IFS= read -r line

Eso no es muy intuitivo, pero así es, recuerde que los proyectiles no estaban destinados a ser utilizados de esa manera.

Lo mismo para echo. echoExpande secuencias. No puede usarlo para contenidos arbitrarios como el contenido de un archivo aleatorio. Necesitas printfaquí en su lugar.

Y, por supuesto, existe el típico olvido de citar su variable en la que todos caen. Entonces es más:

while IFS= read -r line; do
  printf '%s\n' "$line" | cut -c3
done < file

Ahora, algunas advertencias más:

  • a excepción de zsh, eso no funciona si la entrada contiene caracteres NUL mientras que al menos las utilidades de texto GNU no tendrían el problema.
  • si hay datos después de la última línea nueva, se omitirá
  • dentro del bucle, stdin se redirige, por lo que debe prestar atención para que los comandos que contiene no lean desde stdin.
  • para los comandos dentro de los bucles, no estamos prestando atención a si tienen éxito o no. Por lo general, las condiciones de error (disco lleno, errores de lectura ...) se manejarán mal, generalmente más mal que con el equivalente correcto .

Si queremos abordar algunos de los problemas anteriores, se convierte en:

while IFS= read -r line <&3; do
  {
    printf '%s\n' "$line" | cut -c3 || exit
  } 3<&-
done 3< file
if [ -n "$line" ]; then
    printf '%s' "$line" | cut -c3 || exit
fi

Eso se está volviendo cada vez menos legible.

Existen otros problemas al pasar datos a los comandos a través de los argumentos o al recuperar su salida en variables:

  • la limitación en el tamaño de los argumentos (algunas implementaciones de utilidades de texto también tienen un límite allí, aunque el efecto de los alcanzados son generalmente menos problemáticos)
  • el carácter NUL (también un problema con las utilidades de texto).
  • argumentos tomados como opciones cuando comienzan con -(o a +veces)
  • varias peculiaridades de los diversos comandos que se usan típicamente en esos bucles como expr, test...
  • Los operadores de manipulación de texto (limitado) de varios shells que manejan caracteres de varios bytes de manera inconsistente.
  • ...

Consideraciones de Seguridad

Cuando comienzas a trabajar con variables de shell y argumentos para comandos , estás ingresando un campo de minas.

Si olvida citar sus variables , olvide el final del marcador de opción , trabaje en entornos locales con caracteres de varios bytes (la norma en estos días), seguramente introducirá errores que tarde o temprano se convertirán en vulnerabilidades.

Cuando quieras usar bucles.

a ser determinado

Stéphane Chazelas
fuente
24
Claro (vívidamente), legible y extremadamente útil. Gracias otra vez. Esta es en realidad la mejor explicación que he visto en cualquier lugar en Internet para la diferencia fundamental entre las secuencias de comandos de shell y la programación.
Comodín el
2
Son publicaciones como estas que ayudan a los principiantes a aprender sobre Shell Scripts y ver sus diferencias sutiles. Debe agregar la variable de referencia como $ {VAR: -default_value} para asegurarse de que no obtenga un valor nulo. y establezca -o nounset para gritarle cuando haga referencia a un valor no definido.
unsignedzero
66
@ A.Danischewski, creo que te estás perdiendo el punto. Sí, cutpor ejemplo, es eficiente. cut -f1 < a-very-big-filees eficiente, tan eficiente como lo obtendría si lo escribiera en C. Lo que es terriblemente ineficiente y propenso a errores es invocar cutpara cada línea de un a-very-big-filebucle de shell, que es el punto que se señala en esta respuesta. Eso coincide con su última declaración sobre la escritura de código innecesario que me hace pensar que tal vez no entiendo su comentario.
Stéphane Chazelas
55
"En 45 años, no hemos encontrado una API mejor que esa para aprovechar el poder de los comandos y hacer que cooperen en una tarea". - en realidad, PowerShell, por ejemplo, ha resuelto el temido problema de análisis pasando datos estructurados en lugar de flujos de bytes. La única razón por la que los shells aún no lo usan (la idea ha estado ahí por bastante tiempo y básicamente se ha cristalizado en algún momento alrededor de Java cuando los tipos de contenedores de listas y diccionarios ahora estándar se convirtieron en la corriente principal) es que sus mantenedores aún no podían ponerse de acuerdo sobre el formato de datos estructurados comunes para usar (.
ivan_pozdeev
66
@ OlivierDulac Creo que es un poco de humor. Esa sección será por siempre TBD.
muru
43

En cuanto a lo conceptual y la legibilidad, los shells generalmente están interesados ​​en los archivos. Su "unidad direccionable" es el archivo, y la "dirección" es el nombre del archivo. Los shells tienen todo tipo de métodos para probar la existencia del archivo, el tipo de archivo, el formato del nombre del archivo (comenzando con globbing). Los shells tienen muy pocas primitivas para tratar con el contenido del archivo. Los programadores de Shell deben invocar otro programa para tratar con el contenido del archivo.

Debido a la orientación del archivo y el nombre del archivo, la manipulación de texto en el shell es realmente lenta, como ya lo ha notado, pero también requiere un estilo de programación confuso y poco claro.

Bruce Ediger
fuente
25

Hay algunas respuestas complicadas, que brindan muchos detalles interesantes para los geeks entre nosotros, pero es realmente bastante simple: procesar un archivo grande en un bucle de shell es demasiado lento.

Creo que el interrogador es interesante en un tipo típico de script de shell, que puede comenzar con un análisis de línea de comandos, configuración del entorno, comprobación de archivos y directorios, y un poco más de inicialización, antes de pasar a su trabajo principal: pasar por un gran archivo de texto orientado a líneas.

Para las primeras partes ( initialization), generalmente no importa que los comandos de shell sean lentos; solo ejecuta unas pocas docenas de comandos, tal vez con un par de bucles cortos. Incluso si escribimos esa parte de manera ineficiente, generalmente tomará menos de un segundo hacer toda esa inicialización, y eso está bien, solo sucede una vez.

Pero cuando pasamos a procesar el archivo grande, que podría tener miles o millones de líneas, no está bien que el script de shell tome una fracción significativa de segundo (incluso si solo son unas pocas docenas de milisegundos) para cada línea, ya que eso podría sumar horas.

Es entonces cuando necesitamos usar otras herramientas, y la belleza de los scripts de shell de Unix es que nos facilitan hacerlo.

En lugar de usar un bucle para mirar cada línea, necesitamos pasar todo el archivo a través de una tubería de comandos . Esto significa que, en lugar de llamar a los comandos miles o millones de veces, el shell los llama una sola vez. Es cierto que esos comandos tendrán bucles para procesar el archivo línea por línea, pero no son scripts de shell y están diseñados para ser rápidos y eficientes.

Unix tiene muchas herramientas maravillosas integradas, que van desde lo simple a lo complejo, que podemos usar para construir nuestras tuberías. Por lo general, comenzaría con los simples y solo usaría los más complejos cuando sea necesario.

También trataría de mantener las herramientas estándar que están disponibles en la mayoría de los sistemas e intentar mantener mi uso portátil, aunque eso no siempre es posible. Y si su idioma favorito es Python o Ruby, quizás no le importe el esfuerzo adicional de asegurarse de que esté instalado en todas las plataformas en las que su software necesita ejecutarse :-)

Herramientas simples incluyen head, tail, grep, sort, cut, tr, sed, join(cuando la fusión de 2 archivos), y awkde una sola línea, entre muchos otros. Es sorprendente lo que algunas personas pueden hacer con la coincidencia de patrones y los sedcomandos.

Cuando se vuelve más complejo, y realmente tiene que aplicar un poco de lógica a cada línea, awkes una buena opción, ya sea una línea (algunas personas ponen scripts awk completos en 'una línea', aunque eso no es muy legible) o en un guión externo corto.

Como awkes un lenguaje interpretado (como su shell), es sorprendente que pueda hacer un procesamiento línea por línea de manera tan eficiente, pero está especialmente diseñado para esto y es realmente muy rápido.

Y luego hay Perluna gran cantidad de otros lenguajes de secuencias de comandos que son muy buenos para procesar archivos de texto y también vienen con muchas bibliotecas útiles.

Y finalmente, hay una buena C antigua, si necesita la máxima velocidad y alta flexibilidad (aunque el procesamiento de texto es un poco tedioso). Pero probablemente sea un mal uso de su tiempo escribir un nuevo programa en C para cada tarea de procesamiento de archivos diferente que encuentre. Trabajo mucho con archivos CSV, así que he escrito varias utilidades genéricas en C que puedo reutilizar en muchos proyectos diferentes. En efecto, esto amplía el rango de 'herramientas Unix rápidas y simples' a las que puedo llamar desde mis scripts de shell, por lo que puedo manejar la mayoría de los proyectos solo escribiendo scripts, ¡lo cual es mucho más rápido que escribir y depurar código C personalizado cada vez!

Algunas sugerencias finales:

  • no olvide iniciar su script de shell principal export LANG=C, o muchas herramientas tratarán sus archivos ASCII simples como Unicode, haciéndolos mucho más lentos
  • ¡también considere la configuración export LC_ALL=Csi desea sortproducir pedidos consistentes, independientemente del entorno!
  • si necesita sortsus datos, eso probablemente tomará más tiempo (y recursos: CPU, memoria, disco) que todo lo demás, así que trate de minimizar la cantidad de sortcomandos y el tamaño de los archivos que están ordenando
  • Una única canalización, cuando es posible, suele ser más eficiente: ejecutar varias canalizaciones en secuencia, con archivos intermedios, puede ser más legible y depurable, pero aumentará el tiempo que toma su programa
Laurence Renshaw
fuente
66
Las tuberías de muchas herramientas simples (específicamente las mencionadas, como cabeza, cola, grep, clasificación, corte, tr, sed, ...) a menudo se usan innecesariamente, específicamente si ya tiene una instancia awk en esa tubería que puede hacer las tareas de esas herramientas simples también. Otro problema a tener en cuenta es que en las tuberías no se puede pasar de manera simple y confiable la información de estado de los procesos en la parte frontal de una tubería a los procesos que aparecen en la parte posterior. Si utiliza para tales canales de programas simples un programa awk, tiene un espacio de estado único.
Janis
14

Sí, pero...

La respuesta correcta de Stéphane Chazelas se basa en concepto de delegación de cada operación de texto para los binarios específicos, como grep, awk, sedy otros.

Como es capaz de hacer muchas cosas por sí mismo, soltar los tenedores puede ser más rápido (incluso que ejecutar otro intérprete para hacer todo el trabajo).

Por ejemplo, eche un vistazo a esta publicación:

https://stackoverflow.com/a/38790442/1765658

y

https://stackoverflow.com/a/7180078/1765658

probar y comparar ...

Por supuesto

¡No hay consideración sobre la entrada del usuario y la seguridad !

¡No escriba aplicaciones web bajo !

Pero para muchas tareas de administración del servidor, donde podría usarse en lugar de , el uso de basil integrado podría ser muy eficiente.

Mi significado:

Escribir herramientas como bin utils no es el mismo tipo de trabajo que la administración del sistema.

¡Entonces no la misma gente!

Donde los administradores de sistemas tienen que saber shell, podrían escribir prototipos utilizando su herramienta preferida (y más conocida).

Si esta nueva utilidad (prototipo) es realmente útil, otras personas podrían desarrollar una herramienta dedicada utilizando un lenguaje más apropiado.

F. Hauri
fuente
1
Buen ejemplo. Su enfoque es ciertamente más eficiente que el de lololux, pero tenga en cuenta cómo la respuesta de tensibai (la forma correcta de hacer esta OMI, es decir, sin usar bucles de shell) es de órdenes de magnitud más rápida que la suya. Y el tuyo es mucho más rápido si no lo usas bash. (más de 3 veces más rápido con ksh93 en mi prueba en mi sistema). bashes generalmente el caparazón más lento. Even zshes el doble de rápido en ese script. También tiene algunos problemas con las variables no citadas y el uso de read. Así que en realidad estás ilustrando muchos de mis puntos aquí.
Stéphane Chazelas
@ StéphaneChazelas Estoy de acuerdo, bash es probablemente el shell más lento que la gente podría usar hoy en día, pero el más utilizado de todos modos.
F. Hauri
@ StéphaneChazelas He publicado una versión en perl en mi respuesta
F. Hauri
1
@Tensibai, se encuentra POSIXsh , AWK , sed , grep, ed, ex, cut, sort, join... todo ello con más fiabilidad que Bash o Perl.
Comodín el
1
@Tensibai, de todos los sistemas afectados por U&L, la mayoría de ellos (Solaris, FreeBSD, HP / UX, AIX, la mayoría de los sistemas Linux integrados ...) no vienen bashinstalados por defecto. bashse encuentra principalmente sólo en Apple MacOS y sistemas GNU (supongo que eso es lo que se llama las principales distribuciones ), aunque muchos sistemas también lo tienen como un paquete opcional (como zsh, tcl, python...)
Stéphane Chazelas