Tema representativo de entrevista

Entrevista de Linux: ¿En qué se diferencian epoll por nivel (Level-Triggered) y por flanco (Edge-Triggered)?

GeneralDifícil
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Un servicio TCP no bloqueante en Linux registra conexiones con epoll. Un cliente tiene 8 KiB listos para lectura, pero el manejador llama a recv una sola vez para 4 KiB y retorna. LT sigue funcionando, mientras que ET ocasionalmente se bloquea para siempre. ¿Por qué ocurre esto y cuáles son los diseños correctos de lectura, escritura, concurrencia y verificación?

Planteamiento y cuándo aplica

Un servicio TCP en Linux configura tanto su socket de escucha como sus sockets conectados en modo no bloqueante y los gestiona con epoll. Un cliente ha colocado 8 KiB en el búfer de recepción de una conexión. Después de que epoll_wait devuelve EPOLLIN, el manejador llama a recv una vez, consume 4 KiB y retorna.

Con el modo por defecto disparado por nivel, LT, la siguiente llamada a epoll_wait suele reportar la conexión nuevamente. Después de habilitar el modo disparado por flanco con EPOLLET, ET, la misma implementación a veces no recibe más notificaciones de lectura y el cliente espera indefinidamente. Explica:

  • qué significan la lista de interés de epoll, la lista de listos (ready list) y la disponibilidad de E/S;
  • por qué los contratos de notificación de LT y ET producen resultados diferentes;
  • cómo debe manejar ET accept, recv, send, EAGAIN, el cierre parcial (half-close) y los errores;
  • qué riesgos adicionales introducen los hilos de trabajo (worker threads), EPOLLONESHOT, la equidad en conexiones muy activas (hot-connection fairness) y la reutilización de descriptores de archivo (FD);
  • cómo confirmar la causa y verificar la solución con un experimento reproducible.

Los valores de 8 KiB y 4 KiB son entradas para la entrevista. No son tamaños fijos para un envío TCP, una llamada al sistema o un mensaje de aplicación. La competencia central es el contrato de disponibilidad de E/S de Linux, aplicable a roles de backend, infraestructura, SRE, sistemas e ingeniería de software en general, por lo que la categoría es general.

Lo que evalúa el entrevistador

Primero, ¿puede el candidato afirmar que epoll reporta disponibilidad (readiness) —si una clase de E/S puede progresar sin bloquearse— y no que ha llegado una solicitud completa ni que una E/S asíncrona ha finalizado? TCP es un flujo de bytes, y un recv puede devolver cualquier cantidad positiva de bytes disponibles en ese momento.

Segundo, ¿comprenden LT y ET más allá de un eslogan? LT continúa reportando mientras se mantenga la condición de disponibilidad solicitada. ET no promete otra notificación por el simple hecho de que la condición siga siendo verdadera. Tras un evento ET, la aplicación debe tratar el FD como accionable hasta que una lectura o escritura no bloqueante devuelva EAGAIN o EWOULDBLOCK. «ET notifica solo una vez» es una afirmación demasiado absoluta y no conduce a una implementación correcta.

Tercero, ¿pueden aplicar la misma regla a lecturas, escrituras y al socket de escucha? Vaciar las lecturas hasta EAGAIN; iterar sobre accept4 hasta EAGAIN; y no monitorear permanentemente EPOLLOUT en un socket que normalmente permite escritura. Monitorearlo únicamente mientras la aplicación tenga datos en búfer pendientes de envío.

Cuarto, ¿pueden gestionar el estado concurrente? EPOLLONESHOT deshabilita un FD tras una notificación. Un hilo de trabajo debe rearmarlo con EPOLL_CTL_MOD después de terminar sus actualizaciones de estado. Rearmar demasiado pronto puede permitir que dos hilos toquen la misma conexión; olvidar rearmar se manifiesta como un bloqueo permanente.

Quinto, ¿pueden conciliar «vaciar hasta EAGAIN» con la equidad del bucle de eventos? Una conexión continuamente ocupada puede acaparar un hilo durante demasiado tiempo. Si la aplicación se detiene antes para aplicar equidad, debe conservar esa conexión en una cola de ejecutables en espacio de usuario en lugar de esperar que ET invente un nuevo flanco.

Preguntas para clarificar antes de responder

  • ¿Son los sockets realmente no bloqueantes? Con ET y un FD bloqueante, la siguiente lectura o escritura puede bloquear el hilo responsable de muchas conexiones.
  • ¿El estancamiento ocurre en accept, lectura o reescritura? Omitir el vaciado en bucle de accept, dejar datos de entrada pendientes, una actualización de EPOLLOUT o un rearme de EPOLLONESHOT pueden parecer todos una conexión atascada.
  • ¿Cómo delimita los mensajes el protocolo de aplicación? TCP no tiene límites de mensaje. Un prefijo de longitud, un delimitador, el estado de un analizador HTTP o el cierre de la conexión determinan cuándo se completa una solicitud.
  • ¿Cuánto lee el manejador y cuándo retorna? Una única lectura de tamaño fijo por evento es el error directo en este caso; realizar trabajo de negocio costoso dentro del bucle de vaciado crea un problema de equidad.
  • ¿Puede una conexión moverse entre hilos? Identifica al responsable de la entrada, salida, cierre y epoll_ctl, y si EPOLLONESHOT está habilitado.
  • ¿Está EPOLLOUT suscrito permanentemente? Los sockets admiten escritura la mayor parte del tiempo. Una suscripción LT permanente puede hacer que epoll_wait retorne inmediatamente en un bucle que consuma CPU.
  • ¿Cómo se manejan EPOLLRDHUP, EPOLLHUP y EPOLLERR? Pueden quedar datos cuando llega HUP, y ERR/HUP se reportan incluso sin suscripción explícita.
  • ¿Puede reutilizarse rápidamente el número de un FD cerrado? Tratar el FD entero como la identidad completa de la conexión puede hacer que un evento o tarea diferida opere sobre una nueva conexión.

La respuesta de 30 segundos

«epoll mantiene una lista de interés y devuelve eventos desde una lista de listos (ready list). Reporta disponibilidad de E/S, no un mensaje completo. LT continúa reportando mientras la condición permanezca lista, por lo que tras leer solo 4 KiB, los datos restantes normalmente hacen que la siguiente espera devuelva el FD. ET no promete reportes repetidos para una condición de disponibilidad que no ha cambiado, por lo que una lectura parcial puede esperar indefinidamente una notificación que nunca llegará.

Todos los FDs en ET deben ser no bloqueantes. Vacía el socket de escucha con accept4 hasta EAGAIN, las lecturas con recv hasta EAGAIN, y las escrituras con send hasta EAGAIN; monitorea EPOLLOUT solo mientras el búfer de salida no esté vacío. Un recv de cero significa un cierre ordenado del lado de escritura del par, mientras que otros errores requieren un manejo independiente. Con múltiples hilos de trabajo, EPOLLONESHOT puede serializar una conexión, pero debe rearmarse con MOD tras las actualizaciones de estado. Un presupuesto de equidad es adecuado, pero detenerse antes de EAGAIN exige una cola de listos en espacio de usuario. Reproduciría el problema con entrada segmentada, lecturas y escrituras parciales, half-close y concurrencia, para luego demostrar que cada conexión alcanza EAGAIN, se rearma correctamente y no cae en bucles activos de consumo de CPU.»

Análisis detallado paso a paso

Paso 1: Establecer el modelo de disponibilidad de epoll

epoll_create1 crea una instancia de epoll, referenciada a su vez por un FD. Conceptualmente, la instancia mantiene dos conjuntos de estado:

  • lista de interés: los FDs y máscaras de eventos registrados mediante epoll_ctl(EPOLL_CTL_ADD/MOD/DEL);
  • lista de listos (ready list): entradas de la lista de interés que actualmente tienen eventos disponibles, a partir de las cuales epoll_wait devuelve resultados.

Legible (readable) significa que una lectura puede obtener datos, EOF o un error en ese momento sin esperar. Escribible (writable) significa que una escritura puede realizar al menos algún progreso; no promete que quepa toda la respuesta. La aplicación sigue llamando a recv, send o accept4 e interpretando el valor de retorno. El evento incita a la acción; el resultado de la llamada al sistema impulsa la máquina de estados.

Paso 2: Comparar LT y ET con los 4 KiB no leídos

El modo LT por defecto se asemeja a poll: mientras el búfer de recepción conserve datos, la disponibilidad de lectura se mantiene y el siguiente epoll_wait puede devolver ese FD nuevamente. Por lo tanto, la implementación incorrecta de «una lectura por evento» puede parecer funcional, a costa de más reactivaciones (wakeups) y llamadas al sistema.

Con EPOLLET, el kernel reporta cambios de estado (flancos) en la disponibilidad. Después de que el manejador consume 4 KiB, quedan otros 4 KiB y el FD sigue estando legible; la aplicación nunca lo llevó al estado de «no hay más datos por ahora». ET no promete otro reporte para ese estado que no ha cambiado, por lo que esperar un nuevo evento puede bloquear indefinidamente.

La regla confiable es tratar un FD devuelto por ET como listo y continuar realizando E/S no bloqueante hasta que devuelva EAGAIN o EWOULDBLOCK. Esos valores significan que ninguna operación posterior puede progresar en ese momento sin bloquearse. Solo entonces la aplicación devuelve la responsabilidad de notificación a epoll.

Paso 3: Convertir la lectura en una máquina de estados explícita

Este esquema en estilo C omite detalles de análisis (parser), ciclo de vida y registro específicos de la aplicación:

c
void drain_read(Connection *conn) {
  unsigned char buf[4096];

  for (;;) {
    ssize_t n = recv(conn->fd, buf, sizeof buf, 0);
    if (n > 0) {
      append_and_parse(conn, buf, (size_t)n);
      continue;
    }
    if (n == 0) {
      conn->peer_write_closed = true;
      break;
    }
    if (errno == EINTR) {
      continue;
    }
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
      break;
    }
    close_with_error(conn, errno);
    return;
  }

  if (conn->peer_write_closed && output_is_empty(conn)) {
    close_connection(conn);
  }
}

n > 0 solo significa que esos bytes han llegado; el analizador aún podría carecer de una solicitud completa. n == 0 es un cierre ordenado del par en un socket de flujo (stream), después de haber consumido los datos ya almacenados en el búfer. EINTR se puede reintentar, EAGAIN/EWOULDBLOCK completa esta pasada de vaciado y otros errores derivan a la ruta de cierre. Una lectura corta no es prueba de que el mensaje esté completo ni de que el socket esté vacío.

El socket de escucha sigue el mismo patrón. Ante un evento de lectura, se itera sobre accept4, aplicando de forma atómica SOCK_NONBLOCK | SOCK_CLOEXEC a cada nueva conexión, hasta obtener EAGAIN. Aceptar solo una conexión puede dejar varadas otras conexiones ya encoladas bajo ET sin una notificación posterior garantizada.

Paso 4: Controlar el almacenamiento en búfer de salida y EPOLLOUT

Intenta enviar la salida almacenada en el búfer de inmediato. Ante un send parcial, avanza el desplazamiento (offset) y continúa. Reintenta en EINTR. Ante EAGAIN/EWOULDBLOCK, retén los bytes restantes y añade EPOLLOUT mediante EPOLL_CTL_MOD. Continúa el vaciado en el siguiente evento de escritura. Elimina EPOLLOUT de la máscara de interés tan pronto como el búfer de salida esté vacío.

Una suscripción permanente a EPOLLOUT produce un modo de fallo diferente: los sockets suelen admitir escritura durante largos periodos, por lo que LT devuelve inmediatamente de forma repetida y el uso de CPU aumenta sin que haya progreso de negocio. ET no elimina los búferes de salida de la aplicación, la contrapresión (backpressure), los límites máximos de búfer ni las políticas para clientes lentos; únicamente modifica el comportamiento de las notificaciones.

Paso 5: Manejar half-close, HUP, ERR y el orden de cierre

EPOLLRDHUP indica que el par del flujo cerró la conexión o su mitad de escritura. EPOLLHUP señala que el par cerró su lado del canal, pero aún pueden quedar datos sin leer; cerrar de inmediato puede descartarlos. EPOLLERR y EPOLLHUP se reportan incluso si no se solicitaron explícitamente. Ante ERR, getsockopt(SO_ERROR) puede recuperar el error pendiente del socket antes de que la aplicación lo registre y lo cierre según la política del protocolo.

La ruta de cierre debe detener primero la asignación de nuevo trabajo y asegurar que las tareas asíncronas no conserven una identidad expirada. Cerrar el FD final que referencia la descripción del archivo abierto subyacente permite al kernel eliminar el registro. Si dup o fork comparten esa descripción, cerrar un solo FD no elimina necesariamente los eventos relacionados de inmediato, por lo que el protocolo de pertenencia debe invocar explícitamente DEL sobre la entrada o cerrar cada referencia. Utiliza un objeto de conexión con ciclo de vida controlado y una generación o token para que la rápida reutilización del FD entero no redirija trabajo antiguo a una nueva conexión.

Paso 6: Usar EPOLLONESHOT para la exclusividad en hilos de trabajo

Varios hilos pueden esperar sobre una misma instancia de epoll. Para un FD en modo ET, el kernel normalmente despierta a un solo hilo cuando el FD pasa a estar listo, pero eso por sí solo no garantiza que la conexión tenga un único propietario durante todo el procesamiento. El trabajo encolado y eventos posteriores aún pueden generar accesos concurrentes.

EPOLLONESHOT deshabilita el FD tras la entrega de un evento. Después de vaciar la E/S y actualizar el estado del protocolo y la máscara de interés, el hilo de trabajo que mantiene viva la conexión llama a epoll_ctl(EPOLL_CTL_MOD) para rearmarlo. El rearme debe ser el último paso de la transferencia. Olvidar MOD detiene la conexión; hacerlo demasiado pronto puede permitir que un nuevo hilo entre antes de que el propietario anterior finalice.

Paso 7: Preservar la corrección de ET y la equidad simultáneamente

Vaciar hasta EAGAIN puede permitir que una conexión continuamente activa acapare un hilo y retrase a otras conexiones. El bucle puede aplicar un presupuesto de bytes, mensajes o tiempo por conexión. Si ese presupuesto expira antes de EAGAIN, no puede simplemente retornar y esperar al kernel. Debe marcar la conexión como ejecutable en una cola de listos en espacio de usuario y reanudarla más adelante hasta que alcance EAGAIN.

La cola debe evitar entradas duplicadas. El cierre de la conexión, la transferencia entre hilos de trabajo y el rearme con EPOLLONESHOT deben compartir el mismo protocolo de pertenencia. Esto preserva el contrato de ET sin permitir que un FD muy activo prive de recursos a otros FDs.

Paso 8: Construir una verificación que exponga flancos perdidos

Una única solicitud normal es insuficiente. Cubre al menos:

  1. una escritura de 8 KiB del cliente mientras el servidor lee deliberadamente como máximo 4 KiB por llamada, evidenciando el bloqueo previo a la corrección en ET y el vaciado posterior hasta EAGAIN;
  2. un mensaje de aplicación dividido en varios envíos con pausas en límites arbitrarios, demostrando que el análisis es independiente del tamaño de un único recv;
  3. envíos restringidos del servidor que provoquen escrituras parciales y EAGAIN, probando que no se pierden bytes y que EPOLLOUT se elimina tras el vaciado;
  4. half-close del par, HUP con datos sin leer, reinicio de conexión (reset) y EINTR;
  5. entregas repetidas de EPOLLONESHOT con varios hilos de trabajo, demostrando que existe exactamente un propietario y un rearme para cada conexión activa;
  6. una conexión muy activa enviando continuamente junto con muchas conexiones lentas, observando la equidad, el uso de CPU, el retraso del bucle de eventos y la longitud de la cola de listos;
  7. creación y cierre rápidos de conexiones, demostrando que el trabajo retrasado no puede afectar a un número de FD reutilizado.

Observa la razón de finalización de cada pasada de recv/send/accept4, los recuentos de EAGAIN, los cambios en la máscara de interés, el rearme de oneshot, la cola de listos de la aplicación, el presupuesto por conexión, el retardo del bucle de eventos y las conexiones que no progresan. Superar la prueba significa tener un estado de bytes y protocolo correcto, sin bloqueos permanentes, sin bucles activos de espera vacíos, sin propietarios concurrentes y sin inanición prolongada de las conexiones lentas.

Respuesta de ejemplo sólida

«Una instancia de epoll mantiene una lista de interés, y epoll_wait devuelve eventos desde su lista de listos. Proporciona disponibilidad de E/S, no mensajes TCP completos ni finalización asíncrona. Los valores de retorno de las llamadas al sistema dirigen la máquina de estados de la conexión.

LT parece recuperarse en este caso porque, tras consumir 4 KiB, quedan otros 4 KiB, por lo que la disponibilidad de lectura persiste y la siguiente espera reporta el FD de nuevo. Con EPOLLET, el FD nunca regresó a un estado no legible. ET no promete notificaciones repetidas para una condición que se mantiene verdadera, por lo que esperar tras una sola lectura puede bloquear la ejecución indefinidamente.

Configuraría tanto el socket de escucha como los conectados en modo no bloqueante. La ruta de accept itera sobre accept4 hasta EAGAIN. La ruta de lectura itera sobre recv hasta EAGAIN, envía los bytes positivos a un analizador incremental, trata el cero como cierre del lado de escritura del par, reintenta en EINTR y cierra ante otros errores. La salida intenta un send inmediatamente y preserva el desplazamiento tras una escritura parcial. Monitorea EPOLLOUT solo tras un EAGAIN con datos aún pendientes y lo remueve tras el vaciado. HUP se vacía antes de cerrar, y ERR se diagnostica con SO_ERROR.

Con varios hilos de trabajo, asigno un único propietario por conexión y puedo usar EPOLLONESHOT: el hilo rearma con MOD solo después de vaciar la E/S, actualizar el estado y calcular la máscara. Si un presupuesto de equidad detiene el trabajo antes de EAGAIN, encolo la conexión en una cola de listos en espacio de usuario sin duplicados en lugar de esperar un flanco inexistente. La identidad de la conexión incorpora estado de ciclo de vida o generación para tolerar con seguridad la reutilización de FDs.

Reproduciría el fallo con una escritura de 8 KiB y un límite de lectura de 4 KiB, y luego añadiría escrituras parciales, half-close, reset, oneshot, múltiples hilos y carga de conexiones muy activas. La solución es válida cuando cada pasada de manejo en ET alcanza EAGAIN o un cierre explícito, se entrega toda la salida, EPOLLOUT no entra en bucle activo, cada conexión oneshot activa se rearma y ninguna conexión queda inactiva o desatendida de forma permanente.»

Errores comunes

  • Tratar la disponibilidad como un mensaje completo → TCP es un flujo de bytes, y un único recv no es un mensaje de aplicación → usa análisis incremental y un búfer de entrada independiente.
  • Reducir ET a «siempre notifica una sola vez» → varios cambios pueden generar varios eventos; lo que falta es la garantía de repetición para un estado listo que no ha cambiado → procesa hasta EAGAIN.
  • Leer una sola vez bajo ET → los datos en búfer quedan pendientes sin un nuevo flanco → itera sobre recv hasta EAGAIN/EWOULDBLOCK.
  • Combinar ET con sockets bloqueantes → el bucle de vaciado puede bloquearse y dejar sin atención a todo el bucle de eventos → establece el modo no bloqueante antes del registro.
  • Aceptar una sola conexión por evento del listener → las conexiones ya encoladas pueden no recibir ninguna notificación posterior → itera sobre accept4 hasta EAGAIN.
  • Suscribirse siempre a EPOLLOUT un socket normalmente escribible hace que la espera retorne constantemente → suscríbete solo con salida pendiente y remuévelo tras el vaciado.
  • Cerrar inmediatamente ante HUP → pueden quedar datos sin leer → vacía a través de la máquina de estados de lectura, luego cierra según el EOF y el estado de salida.
  • Olvidar rearmar EPOLLONESHOT el FD permanece deshabilitado en la lista de interés → usa EPOLL_CTL_MOD después de actualizar el estado.
  • Detenerse por equidad y esperar otro evento ET → el FD puede permanecer listo sin que haya un nuevo flanco → reanúdalo desde una cola de listos en espacio de usuario.
  • Usar únicamente el FD entero como identidad → una nueva conexión puede reutilizar el número tras el cierre → utiliza un ciclo de vida controlado y una identidad basada en generaciones.
  • Afirmar que ET es intrínsecamente más rápido → los resultados dependen de la proporción de actividad, las llamadas al sistema, el trabajo de la aplicación y la corrección de la implementación → mide CPU, latencia, throughput y equidad bajo una carga representativa.

Preguntas de seguimiento y cómo responder

Pregunta de seguimiento 1: ¿Por qué una lectura corta con recv no demuestra que el socket se haya vaciado?

recv normalmente devuelve lo que esté disponible en ese momento hasta la longitud solicitada. La segmentación de red, la planificación de hilos y la sincronización de envíos pueden producir una lectura corta mientras más bytes llegan instantes después. El límite de vaciado en ET es un EAGAIN/EWOULDBLOCK no bloqueante; el límite del mensaje de aplicación proviene del analizador del protocolo. Son límites distintos.

Pregunta de seguimiento 2: ¿Es ET siempre más rápido que LT?

El modo por sí solo no garantiza eso. ET puede reducir notificaciones repetidas para un FD continuamente listo, pero añade la complejidad del vaciado, colas en espacio de usuario y gestión de estados. Cuando la mayoría de las conexiones están inactivas, el trabajo de la aplicación domina, o la implementación añade muchas llamadas a epoll_ctl, la ganancia puede ser marginal. Mide CPU, llamadas al sistema, throughput, p99 y equidad con el número objetivo de conexiones y su distribución de actividad, priorizando siempre la corrección.

Pregunta de seguimiento 3: ¿Por qué remover EPOLLOUT después de vaciar el búfer de salida?

Escribible suele significar que el búfer de envío del kernel puede aceptar al menos algunos bytes, condición que se mantiene verdadera para muchas conexiones. Monitorearlo sin tener salida pendiente en la aplicación genera notificaciones inútiles y puede ocasionar un bucle activo de consumo de CPU en LT. Monitorea escrituras solo mientras queden bytes; cuando surja nueva salida de la aplicación más adelante, intenta send de inmediato antes de suscribirte de nuevo.

Pregunta de seguimiento 4: ¿Son EPOLLONESHOT y ET la misma característica?

No. EPOLLET modifica el comportamiento de notificación de disponibilidad. EPOLLONESHOT deshabilita el FD tras la entrega de un evento hasta que la aplicación lo rearma con EPOLL_CTL_MOD. Se pueden combinar. ONESHOT ayuda a transferir la propiedad entre hilos de trabajo, pero ET sigue requiriendo un vaciado correcto y la aplicación aún necesita un protocolo de rearme.

Pregunta de seguimiento 5: El presupuesto de equidad expiró antes de EAGAIN; ¿cómo evitas perder el evento?

Marca la conexión como aún ejecutable en espacio de usuario y colócala en una cola de listos sin duplicados. El planificador reanuda recv/send hasta obtener EAGAIN, un cierre u otra expiración del presupuesto. Mantén un propietario válido mientras esté encolada. Con ONESHOT, rearma solo después de que el trabajo en espacio de usuario haya concluido y la conexión requiera nuevamente notificación del kernel.

Pregunta de seguimiento 6: ¿Por qué preocuparse por eventos antiguos tras cerrar un FD?

Un lote de eventos puede encontrarse ya en espacio de usuario, y un hilo de trabajo asíncrono puede conservar una referencia a la conexión mientras el kernel asigna rápidamente el mismo número entero a un nuevo socket. Múltiples FDs también pueden referenciar una misma descripción de archivo abierto. La ruta de cierre debe detener nuevos despachos, gestionar el ciclo de vida de los objetos e invalidar tokens antiguos. Comparar solo el FD entero no puede demostrar que un evento pertenezca a la conexión actual.

Fuentes públicas

Preguntas relacionadas