Introduccion
En este artículo, trataremos de entender cómo el mecanismo epoll difiere de los puertos de finalización en la práctica (Puerto de finalización de E / S de Windows o IOCP). Esto puede ser interesante para los arquitectos de sistemas que diseñan servicios de red de alto rendimiento o los programadores que transfieren el código de red de Windows a Linux o viceversa.
Ambas tecnologías son muy efectivas para manejar una gran cantidad de conexiones de red.
Se diferencian de otros métodos en los siguientes puntos:
- No hay restricciones (excepto los recursos totales del sistema) en el número total de descriptores observados y tipos de eventos
- El escalado funciona bastante bien: si ya está monitoreando los descriptores de N, entonces cambiar a monitorear N + 1 tomará muy poco tiempo y recursos
- Es bastante fácil usar un grupo de subprocesos para procesar eventos en paralelo
- No tiene sentido usar conexiones de red individuales. Todos los beneficios comienzan a aparecer con más de 1000 conexiones
Parafraseando todo lo anterior, ambas tecnologías están diseñadas para desarrollar servicios de red que procesan muchas conexiones entrantes de los clientes. Pero al mismo tiempo, existe una diferencia significativa entre ellos y al desarrollar los mismos servicios es importante saberlo.
(Upd: este artículo es una
traducción )
Tipo de notificaciones
La primera y más importante diferencia entre epoll e IOCP es cómo se le notifica un evento.
- epoll le indica cuándo el descriptor está listo para poder hacer algo con él: " ahora puede comenzar a leer los datos "
- IOCP le informa cuándo se completa la operación solicitada: " solicitó leer los datos y aquí se leen "
Cuando use la aplicación epoll:
- Decide qué operación desea realizar con algún descriptor (lectura, escritura o ambos)
- Establece la máscara apropiada usando epoll_ctl
- Llama a epoll_wait, que bloquea el hilo actual hasta que ocurre al menos un evento esperado (o el tiempo de espera expira)
- Itera sobre los eventos recibidos, toma un puntero al contexto (desde el campo data.ptr)
- Inicia el procesamiento de eventos según su tipo (operaciones de lectura, escritura o ambas)
- Una vez completada la operación (lo que debería suceder inmediatamente), continúa esperando a que se reciban / envíen los datos.
Al usar la aplicación IOCP:
- Inicia alguna operación (ReadFile o WriteFile) para algún descriptor, utilizando el argumento OVERLAPPED no vacío. El sistema operativo agrega el requisito de realizar esta operación a su cola, y la función llamada inmediatamente (sin esperar a que se complete la operación) regresa.
- Llama a GetQueuedCompletionStatus () , que bloquea el subproceso actual hasta que se complete exactamente una de las solicitudes agregadas anteriormente. Si se han completado varios, solo se seleccionará uno de ellos.
- Procesa la notificación recibida de la finalización de la operación utilizando la tecla de finalización y un puntero a OVERLAPPED.
- Continúa esperando que los datos sean recibidos / enviados
La diferencia en el tipo de notificaciones hace posible (y bastante trivial) emular IOCP usando epoll. Por ejemplo, el proyecto
Wine hace exactamente eso. Sin embargo, hacer lo contrario no es tan simple. Incluso si tiene éxito, es probable que resulte en una pérdida de rendimiento.
Disponibilidad de datos
Si planea leer datos, entonces su código debe tener algún tipo de búfer donde planea leerlos. Si planea enviar datos, debe haber un búfer con datos listos para ser enviados.
- epoll no está preocupado en absoluto por la presencia de estos buffers y no los usa de ninguna manera
- IOCP estos tampones son necesarios. El objetivo de usar IOCP es el trabajo en el estilo de "léame 256 bytes de este socket a este búfer". Formamos tal solicitud, se la dimos al sistema operativo, estamos esperando la notificación de la finalización de la operación (¡y no toquemos el búfer en este momento!)
Un servicio de red típico funciona con objetos de conexión, que incluirán descriptores y memorias intermedias asociadas para leer / escribir datos. Por lo general, estos objetos se destruyen cuando se cierra el zócalo correspondiente. Y esto impone algunas limitaciones cuando se usa IOCP.
IOCP funciona agregando a las solicitudes de cola para leer y escribir datos, estas solicitudes se ejecutan en el orden de la cola (es decir, en algún momento posterior). En ambos casos, los almacenamientos intermedios transferidos deben continuar existiendo hasta la finalización de las operaciones requeridas. Además, uno ni siquiera puede modificar los datos en estos búferes mientras espera. Esto impone limitaciones importantes:
- No puede usar variables locales (ubicadas en la pila) como un búfer. El búfer debe validarse antes de que se complete la operación de lectura / escritura, y la pila se destruye cuando sale la función actual
- No puede reasignar el búfer sobre la marcha (por ejemplo, resultó que necesita enviar más datos y desea aumentar el búfer). Solo puede crear un nuevo búfer y una nueva solicitud de envío
- Si escribe algo como un proxy, cuando se lean y envíen los mismos datos, tendrá que usar dos buffers separados para ellos. No puede pedirle al sistema operativo que lea datos en un búfer en una solicitud, y en otra solicitud envíe estos datos allí mismo
- Debe pensar detenidamente cómo su clase de administrador de conexión destruirá cada conexión en particular. Debe tener una garantía total de que, en el momento de la destrucción de la conexión, no hay una sola solicitud para leer / escribir datos utilizando los búferes de esta conexión
Las operaciones de IOCP también requieren pasar un puntero a una estructura OVERLAPPED, que también debe seguir existiendo (y no reutilizarse) hasta la finalización de la operación esperada. Esto significa que si necesita leer y escribir datos al mismo tiempo, no puede heredar de la estructura OVERLAPPED (una idea que a menudo viene a la mente). En su lugar, debe almacenar las dos estructuras OVERLAPPED en su propia clase separada, pasando una de ellas en solicitudes de lectura y la otra en solicitudes de escritura.
epoll no utiliza ninguna memoria intermedia que le haya pasado del código de usuario, por lo que todos estos problemas no tienen nada que ver con eso.
Cambiar las condiciones de espera
Agregar un nuevo tipo de eventos esperados (por ejemplo, estábamos esperando la oportunidad de leer datos desde el socket, y ahora también queríamos poder enviarlos) es posible y bastante simple tanto para epoll como para IOCP. epoll le permite cambiar la máscara de eventos esperados (en cualquier momento, incluso desde otro hilo), y IOCP le permite iniciar otra operación para esperar un nuevo tipo de evento.
Sin embargo, cambiar o eliminar los eventos esperados es diferente. epoll aún le permite modificar la condición llamando a epoll_ctl (incluso desde otros hilos). IOCP se está volviendo más difícil. Si se planificó una operación de E / S, se puede cancelar llamando a la función
CancelIo () . Peor aún, solo el mismo hilo que inició la operación inicial puede llamar a esta función. Todas las ideas de organizar un flujo de control separado están rotas sobre esta limitación. Además, incluso después de llamar a CancelIo (), no podemos estar seguros de que la operación se cancelará de inmediato (ya puede estar en progreso, utiliza la estructura OVERLAPPED y el búfer pasado para lectura / escritura). Todavía tenemos que esperar hasta que se complete la operación (su resultado será devuelto por la función GetOverlappedResult ()) y solo después de eso podemos liberar el búfer.
Otro problema con IOCP es que una vez que se ha programado la ejecución de una operación, ya no se puede cambiar. Por ejemplo, no puede cambiar la solicitud de ReadFile programada y decir que desea leer solo 10 bytes, no 8192. Debe cancelar la operación actual e iniciar una nueva. Esto no es un problema para epoll, que cuando comienza a esperar, no tiene idea de cuántos datos desea leer en el momento en que llega la notificación sobre la capacidad de leer datos.
Conexión sin bloqueo
Algunas implementaciones de servicios de red (servicios relacionados, FTP, p2p) requieren conexiones salientes. Tanto epoll como IOCP admiten una solicitud de conexión sin bloqueo, pero de diferentes maneras.
Cuando se usa epoll, el código generalmente es el mismo que para select o poll. Crea un socket sin bloqueo, llama a connect () y espera una notificación sobre su disponibilidad para escribir.
Al usar IOCP, debe usar la función ConnectEx por separado, ya que la llamada a connect () no acepta la estructura OVERLAPPED, lo que significa que no puede generar una notificación sobre el cambio de estado del socket más adelante. Por lo tanto, el código de inicio de la conexión no solo diferirá del código que usa epoll, sino que también diferirá del código de Windows que usa select o poll. Sin embargo, los cambios pueden considerarse mínimos.
Curiosamente, accept () funciona con IOCP como de costumbre. Existe una función AcceptEx, pero su función no tiene relación alguna con una conexión sin bloqueo. Esto no es una "aceptación sin bloqueo", como podría pensar por analogía con connect / ConnectEx.
Monitoreo de eventos.
A menudo, después de la activación de un evento, los datos adicionales llegan muy rápidamente. Por ejemplo, esperábamos que la entrada del socket llegara usando epoll o IOCP, obtuvimos un evento sobre los primeros bytes de datos, y justo allí, mientras los leíamos, llegaron otros cien bytes. ¿Puedo leerlos sin reiniciar la supervisión de eventos?
Usar epoll es posible. Obtiene el evento "ahora se puede leer algo", y lee todo lo que se puede leer desde el socket (hasta que obtenga el error EAGAIN). Lo mismo con el envío de datos: cuando recibe una señal de que el socket está listo para enviar datos, puede escribir algo en él hasta que la función de escritura devuelva EAGAIN.
Con IOCP esto no funcionará. Si le pidió al socket que lea o envíe 10 bytes de datos, esa es la cantidad que se leerá / enviará (incluso si ya se podría hacer más). Para cada bloque posterior, debe realizar una solicitud por separado utilizando ReadFile o WriteFile, y luego esperar hasta que se ejecute. Esto puede crear un nivel adicional de complejidad. Considere el siguiente ejemplo:
- La clase de socket creó una solicitud para leer datos llamando a ReadFile. Los hilos A y B esperan el resultado llamando a GetOverlappedResult ()
- La operación de lectura se completó, el hilo A recibió una notificación y llamó a un método de clase de socket para procesar los datos recibidos
- La clase de socket decidió que estos datos no son suficientes, deberíamos esperar lo siguiente. Coloca otra solicitud de lectura.
- Esta solicitud se ejecuta de inmediato (los datos ya llegaron, el sistema operativo puede enviarlos de inmediato). La secuencia B recibe notificación, lee los datos y los pasa a la clase de socket.
- Por el momento, la función de leer datos en la clase de socket se llama desde los flujos A y B, lo que conlleva el riesgo de corrupción de datos (sin usar objetos de sincronización) o pausas adicionales (cuando se usan objetos de sincronización)
Con los objetos de sincronización en este caso, generalmente es difícil. Bueno, si él está solo. Pero si tenemos 100,000 conexiones y cada una de ellas tendrá algún tipo de objeto de sincronización, esto puede afectar seriamente los recursos del sistema. ¿Y si aún conserva 2 (en caso de separación de las solicitudes de procesamiento para lectura y escritura)? Aún peor
La solución habitual aquí es crear una clase de administrador de conexión que será responsable de llamar a ReadFile o WriteFile para la clase de conexión. Esto funciona mejor, pero hace que el código sea más complejo.
Conclusiones
Tanto epoll como IOCP son adecuados (y se usan en la práctica) para escribir servicios de red de alto rendimiento que pueden manejar una gran cantidad de conexiones. Las tecnologías en sí mismas difieren en la forma en que manejan los eventos. Estas diferencias son tan significativas que apenas vale la pena intentar escribirlas en una base común (la cantidad del mismo código será mínima). Varias veces trabajé tratando de llevar ambos enfoques a algún tipo de solución universal, y cada vez el resultado fue peor en términos de complejidad, legibilidad y soporte en comparación con dos implementaciones independientes. El resultado universal obtenido tuvo que ser abandonado cada vez.
Al portar código de una plataforma a otra, generalmente es más fácil portar el código IOCP para usar epoll que viceversa.
Consejos:
- Si su tarea es desarrollar un servicio de red multiplataforma, debe comenzar con una implementación de Windows utilizando IOCP. Una vez que todo esté listo y depurado, agregue un epoll-backend trivial.
- No debe intentar escribir las clases generales Connection y ConnectionMgr que implementan la lógica epoll e IOCP al mismo tiempo. Se ve mal desde el punto de vista de la arquitectura del código y conduce a un montón de todo tipo de #ifdef con una lógica diferente dentro de ellos. Mejor hacer clases base y heredar implementaciones separadas de ellas. En las clases base, puede mantener algunos métodos o datos generales, si los hay.
- Supervise de cerca la vida útil de los objetos de la clase Connection (o como se llame a la clase donde se almacenarán las memorias intermedias para los datos recibidos / enviados). No debe destruirse hasta que se completen las operaciones de lectura / escritura programadas utilizando sus memorias intermedias.