본문 바로가기
프로그래밍/리눅스

프로그래밍 「 리눅스 편」프로토타입 코드를 사용한 비동기 프로그래밍의 기본 사항 이해

by grapedoukan 2023. 6. 15.
728x90

비동기 I/O(Async I/O)는 미래/약속이 있는 프론트엔드 개발, API를 처리하는 백엔드 시스템 및 분산 작업 처리와 같은 다양한 컨텍스트에서 널리 활용됩니다. 특히 웹 응용 프로그램에서 효율성과 인기는 타의 추종을 불허합니다. 이 영역에 대한 개인적인 탐구는 흔하지 않은 네트워크 오류가 발생했을 때 시작되어 gevent에 대해 더 깊이 들여다보게 되었습니다. 이 동시성 라이브러리는 백엔드 스택 전반에 걸쳐 광범위하게 사용되었으며, gunicorn과 Celery는 gevent 작업자를 활용했습니다. gevent를 이해하는 것은 이 여정의 중요한 초기 단계였으며, 결국 gevent를 구동하는 이벤트 루프 중 하나인 libuv를 발견하게 되었습니다.

그러나 개념을 배우는 가장 효과적인 방법은 스스로 구축하는 것입니다. 이러한 깨달음은 시스템 호출과 간단한 C 프로그래밍을 사용하여 Async I/O를 핵심으로 구성하도록 영감을 주었습니다. 코드 작성을 시작하면서 비동기 I/O의 진정한 본질이 훨씬 더 명확해졌으며 이 기사를 통해 이 탐색에서 얻은 지식을 공유하는 것을 목표로 삼았습니다.

이 문서는 세 가지 주요 섹션으로 나뉩니다.

  1. 소개

몇 가지 기록을 제공하고 이 문서에 대한 컨텍스트를 설정합니다.

2. 동기 클라이언트-서버 응용 프로그램 작성

이렇게 하면 소켓 IO에서 사용되는 핵심 API에 대해 알게 될 것입니다. 소켓 IO는 웹 응용 프로그램에서 발생하는 가장 쉽고 일반적인 IO 작업이기 때문에 선택됩니다. 이 외에도 비동기 디스크 IO와 같은 다른 IO가 약간 관련되어 있습니다. 흥미로운 정보를 보려면  기사를 읽으십시오.

3. 비동기 클라이언트-서버 응용 프로그램 작성

이것은 O_NONBLOCK 플래그와 fcnt syscall (정말 흥미 롭습니다)에 대한 사용을 노출합니다. 또한 비동기 함수를 처리하는 고유한 특성을 살펴보겠습니다., 동기 함수를 사용하는 일반적인 프로그램 제어 흐름과 다르기 때문에.

4. epoll을 사용하여 비동기 IO의 효율성 향상

이 섹션에서는 우리가 알고 사랑하는 이벤트 기반 프로그래밍에 더 가까이 다가갈 것입니다. 이 섹션에서는 이벤트 루프(예: libuv)가 이해되기 시작하는 단계를 설정합니다.

 

역사

C10k 문제

닷컴이 블룸하는 동안 C10k 문제는 웹 개발자를 괴롭혔습니다. 수만 개의 동시 웹 연결을 처리하는 문제였습니다. 각 연결을 처리하기 위해 스레드 또는 프로세스를 사용하는 기존 방법은 다음과 같은 이유로 이러한 문제에 대해 충분히 확장 가능하지 않았습니다.

  1. 각 연결은 수명이 짧습니다. 지금도 몇 ms 이상 지속되는 API 연결은 눈살을 찌푸리게 합니다(매우 특별한 경우가 아닌 한). 스레드 생성(및 정리)은 이러한 높은 변동률과 일치할 수 없습니다.
  2. 높은 컨텍스트 스위치.

프로세스 기반 병렬 처리는 의문의 여지가 없었고 (스레드보다 무거움) 이러한 새로운 종류의 문제를 처리하기 위해 확장성이 뛰어난 아키텍처가 필요했습니다.

그 당시 (2004 년경) 기존 웹 서버 (Apache)가 이러한 높은 수요를 충족시키기 위해 고군분투하는 동안 새로운 서버 NGINX가 흥미로운 아키텍처로 시장에 출시되었으며 매우 잘 확장되었습니다. nginx의 이 기사에서 이에 대한 자세한 내용을 확인하십시오.

비슷한 맥락에서 Ryan Dahl이라는 사람이 있었는데, 그는 서버의 느린 성능에 좌절하고 Async IO를 기반으로 nodejs를 개발했습니다. 인용 :

"Dahl은 2009년에 가장 인기 있는 웹 서버인 Apache HTTP Server가 많은 동시 연결(최대 10,000개 이상)과 코드를 생성하는 가장 일반적인 방법(순차 프로그래밍)을 처리할 수 있는 제한된 가능성을 비판했습니다.

— 위키피디아, 노드의 역사.js

C10k의 문제는 다음과 같은 일반적인 문제를 지적했습니다.

  1. 차단된 코드
  2. 확장성 부족

그리고 위의 문제는 대부분 Async IO (Nginx 및 nodejs에서 볼 수 있음)에 의해 처리되어 가장 일반적으로 사용되는 패턴 (현재까지)이되었습니다.

javascript/nodejs 아키텍처에 대해 간략히 살펴보겠습니다.

여기에는 많은 너트와 볼트가 관련되어 있지만 이 아키텍처의 핵심 부분은 차단 및 비차단 작업입니다. 따라서 비동기 IO(비차단 IO가 의미하는 바)에 대한 이해는 이러한 시스템이 어떻게 작동하는지 이해하는 데 도움이 될 것입니다. 시작하자!

 

소켓 소개

우리는 주로 소켓을 통한 비동기 IO를 다루기 때문에 소켓 IO가 실제로 어떻게 작동하는지에 대한 배경 지식을 제공하는 것이 좋습니다.

가장 일반적인 사용 사례인 클라이언트-서버 모델을 고려할 것입니다.

소켓 시스템 호출 목록은 다음과 같습니다.

  1. socket: 통신에 사용할 수 있는 파일 설명자를 반환합니다.
  2. bind: 서버에 연결하기 위해 클라이언트가 사용하는 주소에 소켓을 바인딩합니다.
  3. listen: 소켓이 연결을 허용할 수 있도록 합니다.
  4. 수락: 수신 대기 소켓이 클라이언트의 연결을 수락할 수 있습니다.
  5. 연결: 한 소켓에서 다른 소켓으로 연결을 설정합니다.

소켓은 클라이언트와 서버 간의 통신을 용이하게 합니다. 프로세스 간 통신 수단입니다 (IPC의 또 다른 매우 일반적인 방법은 파이프입니다). 소켓 I/O 통신의 초기 단계에는 소켓을 만들고 서버 주소에 바인딩하는 작업이 포함됩니다. 클라이언트의 경우 소켓이 생성되면 연결할 준비가 된 것입니다. 소켓은 후속 작업에 사용할 수 있는 파일 설명자를 반환하는 소켓 시스템 호출(이하 syscall이라고 함)을 사용하여 만들 수 있습니다.

프로토타입:

#include <sys/socket.h>
int socket(int domain, int type, int protocol);

연결 도메인은 소켓 및 통신 범위(동일한 호스트 또는 원격 컴퓨터)를 식별하는 방법을 정의합니다. 대부분의 OS는 최소한 다음 도메인을 지원합니다.

  1. AF_UNIX(Unix 도메인 소켓): 이 도메인은 동일한 호스트에서 통신할 수 있습니다.
  2. AF_INET/AF_INET6: IPv4/IPv6를 통해 동일하거나 다른 호스트에 있는 애플리케이션 간의 통신을 허용합니다.

AF는 주소 제품군을 의미합니다.

AF_UNIX의 경우 주소 구조는 sockaddr_un로 정의됩니다. AF_INET 및 AF_INET6의 경우 사용되는 주소 구조는 각각 sockaddr_in  sockaddr_in6입니다.

소켓 유형에는 두 가지 유형이 있습니다.

  1. SOCK_STREAM: 양방향 데이터 흐름이 있는 신뢰할 수 있는 바이트 스트림 채널인 스트림 소켓. 이들은 쌍으로 작동하기 때문에 연결 지향이라고 합니다. 이것에 대한 비유는 전화를 통해 사람과 이야기하는 것입니다.
  2. SOCK_DGRAM: 데이터를 메시지로 교환할 수 있는 데이터그램 소켓(데이터그램이라고 함). 이들은 신뢰할 수 없으며 순서가 맞지 않을 수 있습니다. 이를 연결 없는 소켓이라고도 합니다. 발신자와 수신자가 동시에 활성 상태일 필요가 없는 이메일이 전송되지 않을 수 있다고 생각하면 메시지가 전달되지 않을 수 있습니다.

프로토콜 인수는 0으로 지정됩니다. 예를 들어 IPPROTO_RAW 소켓에 사용됩니다. 몇 가지 일반적인 프로토콜은 다음과 같이 정의됩니다.

/* Standard well-defined IP protocols.  */
enum
  {
    IPPROTO_IP = 0,    /* Dummy protocol for TCP.  */
#define IPPROTO_IP  IPPROTO_IP
    IPPROTO_ICMP = 1,    /* Internet Control Message Protocol.  */
#define IPPROTO_ICMP  IPPROTO_ICMP
    IPPROTO_IGMP = 2,    /* Internet Group Management Protocol. */
#define IPPROTO_IGMP  IPPROTO_IGMP
    IPPROTO_IPIP = 4,    /* IPIP tunnels (older KA9Q tunnels use 94).  */
#define IPPROTO_IPIP  IPPROTO_IPIP
    IPPROTO_TCP = 6,    /* Transmission Control Protocol.  */
/* snip */
}

이것은 /usr/include/netinet/in.h에서 참조할 수 있습니다.

소켓 시스템 호출의 나머지 부분은 나중에 코드를 진행하면서 설명합니다.

 

소켓 IO

그림 1. 소켓의 스트리밍 IO 개요Overview of streaming IO in sockets

서버는 수동적이기 때문에 수동 소켓이라고하며 무언가가 연결되기를 기다립니다. 클라이언트는 능동적으로 연결을 열고 일부 작업 (데이터 전송)을 수행 한 다음 닫을 때 활성 소켓으로 알려져 있습니다.

기술적으로 연결이 있는 소켓은 활성(활성 열림이라고 함)이고 수신 대기가 있는 소켓은 수동(수동 열림이라고 함)입니다. 기본적으로 socket은 활성 상태입니다.

 

동기 SOCKET IO 쓰기

서버 코드를 작성해 봅시다

 

이러한 각 함수 호출에 대해 자세히 살펴보겠습니다. 주요 기능을 알아차리면 그림 1과 같이 정확히 기반으로 합니다. Create, bind, listen, accept, handle request 및 close는 정확히 같은 순서로 호출됩니다.

int main(int argc, char *argv[])
{
    int sockfd = create_socket();
    bind_address(sockfd);
    if (listen(sockfd, BACKLOG) == -1)
    {
        printf("ERROR on listen");
        exit(1);
    }
    int afd = accept_connection(sockfd);
    handle_client_request(afd);
    close_socket(sockfd);
    return 0;
}

가장 먼저해야 할 일은 소켓을 만드는 것입니다 (서버와 클라이언트 모두에 적용됨). 소켓 syscall을 호출하여 수행됩니다.

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

인수 AF_INET 도메인을 IPv4로 정의하고 소켓SOCK_STREAM 스트리밍 소켓으로 설정하면 프로토콜이 0으로 설정됩니다. 이 syscall은 sockfd에 저장된 소켓 파일 설명자를 반환합니다. 파일 설명자에 대하여-

파일 디스크립터, 프로세스의 열린 파일 디스크립터 테이블에 있는 항목에 대한 인덱스인 음수가 아닌 작은 정수입니다. 파일 디스크립터는
후속 시스템 호출(read(2), write(2), lseek(2), fcntl(2) 등)
에서 열려 있는 파일을 참조하는 데 사용됩니다. 성공적인 호출에서
반환된 파일 설명자는 현재 프로세스에 대해 열려 있지 않은
가장 낮은 번호의 파일 설명자입니다.

— 파일 디스크립터에 대한 open(2)에 대한 매뉴얼 페이지

소켓이 생성되면 주소에 바인딩해야 합니다. 우리는 서버 응용 프로그램을 만들고 있으며 클라이언트는 이 응용 프로그램에 액세스하기 위해 IP와 포트를 알아야 합니다. 바인드는 이 문제를 해결합니다.

void bind_address(int sockfd)
{
    struct sockaddr_in serv_addr;
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    serv_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // use 127.0.0.1

    if (bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
    {
        printf("ERROR on binding");
        exit(1);
    }
}

bind 의 인수는 소켓 파일 디스크립터이며, 이미 sockfd 로 가지고 있습니다. 두 번째 및 세 번째 매개 변수는 소켓 주소와 소켓 주소의 길이입니다.

소켓 주소는 구조 sockaddr_in에 의해 정의됩니다(sockaddr_un AF_UNIX 유형의 도메인에 사용할 수 있음).

struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // use 127.0.0.1

이것은 바인딩이 기대하는 것이므로 sockaddr * 를 struct 하기 위해 캐스팅해야합니다. 이것은 동일한 바인딩 매개 변수 (struct sockaddr * 유형)가 다른 주소 유형을 허용 할 수있는 다형성입니다.

htons/htonl은 호스트 바이트 순서를 네트워크 바이트 순서로 변환합니다. 이는 LSB/MSB가 아키텍처(리틀 엔디안/빅 엔디안)에 따라 다르지만 네트워크에서 데이터를 이해하기 위해 LSB와 MSB를 알 수 있는 균일한 방법이 필요하기 때문에 필요합니다. 따라서 이러한 편의 기능을 사용하면 이러한 변환이 쉽게 발생할 수 있습니다.

이제 주소에 바인딩이 완료되었으며 서버는 listen syscall로 수신을 시작할 수 있습니다.

    if (listen(sockfd, BACKLOG) == -1)
    {
        printf("ERROR on listen");
        exit(1);
    }

백로그 매개 변수는 수락을 위해 큐에 대기할 수 있는 연결 수를 정의합니다. 여기서 주목해야 할 중요한 점은 듣기가 차단되지 않는다는 것입니다. 따라서 여러 클라이언트 연결을 처리 할 수있는 웹 서버를 만들 계획이라면 소켓 생성부터 청취 할 때까지 소켓 설정이 동일하게 유지됩니다. 이 아래에 어떤 명령이 오든 크기를 조정해야 합니다.

다음은 다음과 같이 정의되는 syscall을 수락합니다.

int accept_connection(int sockfd)
{
    printf("Listening\n");
    int afd = accept(sockfd, NULL, 0);
    if (afd < 0)
    {
        printf("ERROR on accept");
        exit(1);
    }
    return afd;
}

두 번째 및 세 번째 매개 변수는 클라이언트 주소를 가져오는 데 사용됩니다.

수락의 프로토 타입은 (man 2는 API에 대한 man 페이지를 참조하며 자세한 내용은 참조)에서 찾을 수 있습니다.man 2 acceptman man

수락 시스템 호출이 차단되고 클라이언트가 연결될 때까지 진행되지 않습니다. 클라이언트가 연결되면 연결된 새 소켓을 만들고 이 소켓을 참조하는 파일 설명자를 반환합니다. 새로 만든 소켓이 수신 대기 상태(sockfd)가 아닙니다. 원래 소켓 sockfd는 이 호출의 영향을 받지 않습니다.

이제 새 소켓 파일 설명자 afd를 클라이언트와의 후속 통신에 사용할 수 있습니다. 원래 소켓은 무료이며 다른 연결을 수락하는 데 사용할 수 있지만 프로그램이 현재 클라이언트를 제공하는 데 사용 중이므로 수행 할 수 없습니다 (이는 중요한 차이점입니다. 청취 논리와 클라이언트 논리를 분리 할 수 있다면 궁극적으로 응용 프로그램을 확장 할 수 있습니다).

handle_client_request 함수는 클라이언트와의 통신을 처리합니다.

void handle_client_request(int afd)
{
    char buf[BUF_SIZE];
    char write_buf[BUF_SIZE] = "Greetings from server \n";

    ssize_t bytes_read = read(afd, buf, READ_BUF);
    if (bytes_read < 0)
    {
        printf("ERROR reading from socket");
        exit(1);
    }
    printf("%.*s\n", (int)bytes_read, buf);

    ssize_t bytes_written = write(afd, write_buf, sizeof(write_buf));
    if (bytes_written != sizeof(write_buf))
    {
        printf("ERROR writing to socket");
        exit(1);
    }
    printf("Send response to client");
}

READ_BUF 길이까지 데이터를 읽고 buf에 저장한 다음 write_buf에 저장된 데이터를 클라이언트에 씁니다. 이 모든 것은 nu라고하는 연결된 소켓에 의해 수행됩니다.afd

위의 코드를 file_name.c (sv.c)로 저장하고 실행하여 컴파일 할 수 있습니다.

gcc -g sv.c -o sv

gcc가 없으면 이것을 설치해야합니다. 우분투 / debain 기반 시스템의 경우 . 설치 후 사용자 환경에 따라 지침을 제공합니다. GCC 플래그는 디버그 기호를 설치하고(gdb를 통해 실행하려는 경우) 출력 파일을 지정합니다. sudo apt install build-essential-g-osv.

다음과 같은 방법으로 응용 프로그램을 실행합니다

./sv

다음과 같은 출력이 표시됩니다.

아무 일도 일어나지 않고 프로그램이 syscall 수락에서 멈춥니다 (이 프로그램을 실행하고 코드를 누르고 단계별로 실행하여 확인할 수 있음). 서버에 연결하려면 클라이언트 프로그램을 만들어야합니다.sudo gdb svr

클라이언트 코드

 

클라이언트는 서버와 비슷한 방식으로 작동합니다. 먼저 소켓을 만든 다음 connect syscall을 호출하여 서버에 연결합니다.

void connect_to_server(int sockfd)
{
    struct sockaddr_in serv_addr;
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    serv_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

    if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
    {
        printf("ERROR on connect");
        exit(1);
    }
}

Connect syscall은 연결하려는 소켓 파일 설명자 및 주소 구조와 길이를 허용합니다. 주소 구조는 서버에 대해 수행된 것과 유사하게 정의됩니다.

이제 위의 코드를 컴파일하십시오.

gcc cl.c -o cl

클라이언트와 서버를 모두 실행합니다. 서버를 먼저 실행해야 합니다. 서버를 실행하기 전에 클라이언트를 실행하면 클라이언트가 연결을 시도 할 포트를 수신하는 응용 프로그램이 없습니다. 아래와 같이 오류가 발생합니다.

./sv 실행하면 서버는 클라이언트가 연결될 때까지 기다립니다. 최종 결과는 다음과 같습니다.

이 코드의 위의 문제는 주어진 시간에 최대 하나의 클라이언트에만 서비스를 제공 할 수 있다는 것입니다. 스레드를 사용하여 이를 확장하고 스레드 내부(각 연결에 대해 하나의 스레드)를 처리하여 이를 확장할 수 있지만 더 나은 방법으로 처리해 보겠습니다.

 

비동기 소켓 IO

이 섹션에서는 둘 이상의 연결을 처리할 수 있도록 서버 논리를 비동기로 만들 것입니다. 이를 수행하는 두 가지 방법이 있는데, 시스템 콜을 수락하는 대신 플래그를 전달할 수 있는 4번째 인수에서 플래그를 사용하는 accept4 SOCK_NONBLOCK 사용합니다. 또 다른 방법은 fcntl 을 호출하여 파일 설명자 (sockfd)를 비 차단으로 변경하는 것입니다. 이 섹션에서는 비동기 IO에 대해 이후 방법을 사용할 것입니다.

수정된 서버 코드를 살펴보겠습니다

 

이 코드와 동기 코드의 차이점은 다음과 같습니다.

void set_socket_nonblocking(int sockfd)
{
    int flags = fcntl(sockfd, F_GETFL, 0); // get old flags
    flags |= O_NONBLOCK;
    if (fcntl(sockfd, F_SETFL, flags) < 0)
    {
        printf("Fail to make socket non-blocking");
        exit(1);
    }
}

위의 코드는 fcntl syscall을 호출하고 지정된 설명자에 대한 플래그를 반환하는 F_GETFL 전달하여 파일 설명자 sockfd에 설정된 현재 플래그를 추가합니다. 새 플래그는 기존 플래그에 ORed(추가)되고 F_SETFL를 사용하여 fnctl을 다시 호출하여 설정됩니다. 플래그에 대한 자세한 내용은 여기와 fcntl 매뉴얼 페이지(man 2 fcntl)에서 확인할 수 있습니다.O_NONBLOCK

위의 코드를 컴파일하고 실행하면 (서버 코드 만 기억하십시오. 클라이언트를 변경하지 않고 동일한 클라이언트 코드를 반복해서 재사용합니다) 다음과 같이 할 수 있습니다.

참고로, 수락은 클라이언트가 연결되기를 기다리지 않고 호출이 단순히 진행되었으며 연결된 소켓 파일 설명자 (afd)를 반환하는 대신 오류, 즉 -1을 반환했습니다. 이 코드에서 배운 점은 함수 호출을 비동기 또는 비 차단으로 만들면 비동기 y 케이스를 처리하기위한 추가 메커니즘을 넣어야한다는 것입니다. 리소스(여기서는 네트워크 IO 또는 소켓)를 사용할 수 있게 될 때마다 이에 반응해야 하므로 Reactor 패턴입니다.

기능을 수행하고 아래 수정 된 코드를 살펴 보겠습니다.

 

변경된 줄은 accept syscall (및 추가 된 루프)에 있습니다.

int accept_connection(int sockfd)
{
    int afd = accept(sockfd, NULL, 0);
    if (afd < 0)
    {
        if (errno == EAGAIN)
        {
            sleep(1); // Can do something useful here
            return -1;
        }
        else
        {
            printf("ERROR on accept");
            exit(1);
        }
    }
    return afd;
}

플래그를 사용하여 syscall로 작업하는 동안 여기서 주목해야 할 중요한 점은 이러한 호출이 IO가 완료될 때까지 기다리지 않기 때문에 해당 실패 값을 반환하고(afd는 -1로 반환됨) 전역 errno 상수가 EAGAIN으로 설정된다는 것입니다.O_NONBLOCK

오류 base.h

아이디어는 afd가 소켓을 가리키는 유효한 파일 설명자일 때 일부 출력(또는 여기에서 처리되지 않는 중단됨)을 얻을 때까지(또는 중단될 때까지) 계속해서 시스템 호출을 수락하려고 시도하는 것입니다.

위의 코드를 실행하면 하나의 연결만 처리할 수 있으므로 동기 코드와 동일하게 작동한다는 것을 알 수 있습니다. 루프에 있으므로 더 많은 수락 요청을 처리할 수 있습니다(다시 순차적으로 한 번에 하나씩). 여기서 집중해야 할 가장 중요한 것은 이 라인입니다.

if (errno == EAGAIN)
{
    sleep(0.1); // Can do something useful here
    return -1;
}

여기서 잠자는 대신 유용한 일을 할 수 있다면 상황이 훨씬 나아질 것입니다.

이제 유용한 것이 다른 요청을 처리 할 수 있습니다.

코드의 또 다른 반복을 시도해 보겠습니다.

 

위의 코드에서 우리는 활성 소켓 파일 설명자 목록을 유지 관리하고 있습니다 (accept syscall이 연결을 얻을 때마다 accept 의해 반환 됨 (즉, 유효한 afd). 연결은 즉시 처리되지 않습니다, 대신 수락이 -1을 반환하고 errno가 EAGAIN일 때마다 다음 루프에서 수행됩니다., 즉, 다시 시도해야 합니다., 요청 처리를 할 수 있습니다(함수에 의해 수행됨). 처리가 완료되면 해당 활성 소켓에 해당하는 파일 설명자를 제거합니다. 이런 식으로, 잠을 자고 아무것도하지 않는 대신 (이전처럼) 유용한 일을하고 있습니다.process_client

이 접근 방식은 약간 번거롭고, 활성 파일 설명자를 처리하고, 추가 및 제거가 약간 까다롭고, 위의 코드에서는 거의 확장되지 않습니다 (제한된 배열 크기로 소유하고 디스크립터 관리가 좋지 않음).

동일한 프로파일링 명령을 다시 실행합니다.

time seq 900 | xargs -I {} -P 0 sh -c './cl'

출력

이것은 정말 흥미 롭습니다, 우리는 몇 가지 변경 (주로 소켓에 대한 파일 설명자를 동기에서 NON 차단으로 변경하고 활성 소켓을 유지하기위한 일부 부기 메커니즘)을 만들었으며 한 번에 하나의 요청을 처리 할 수있는 프로그램은 이제 많은 소란없이 900 개의 요청을 쉽게 제공합니다.

이 시점에서 우리는 콜백과 다소 유사합니다. process_client 해당 IO를 사용할 수 있을 때 호출되는 콜백 함수입니다(향후 해결됨).

 

EPOLL 입력

epoll API는 여러 파일 디스크립터를 동시에 모니터링하여 I/O 작업을 수행할 준비가 되었는지 확인할 수 있는 Linux 전용 인터페이스입니다. 이전 섹션에서 설명한 것처럼 파일 설명자를 수동으로 모니터링하는 것과 비교할 때 epoll은 다음과 같은 몇 가지 이점을 제공합니다.

a) epoll은 폴링과 준비된 I/O 감지 사이의 지연을 최소화하여 대기 시간을 줄입니다. 수동 폴링을 사용하는 경우 각 폴링 사이에 상당한 시간 간격이 있을 수 있으며, 이로 인해 대기 시간이 길어질 수 있습니다.

b) epoll은 타이트한 루프에서 지속적으로 폴링할 필요가 없기 때문에 CPU 사용량 측면에서 더 효율적입니다. #1에서 언급한 문제를 피하기 위해 코드에서 긴밀한 루프로 폴링하면 CPU 주기가 낭비됩니다(대기 중).

c) epoll은 커널에 대한 시스템 호출 수를 최소화합니다. 수동 폴링을 사용하면 각 파일 설명자의 상태를 개별적으로 확인하기 위해 여러 시스템 호출이 필요할 수 있습니다. 대조적으로, epoll은 이러한 작업을 통합하여 시스템 호출 수를 줄이고 전반적인 성능을 향상시킵니다.

epoll은 세 개의 syscall로 구성됩니다.

  1. epoll_create(): epoll 인스턴스 생성
  2. epoll_ctl(): 모니터링하려는 파일 설명자 추가 또는 제거
  3. epoll_wait(): 모니터링 중인 파일 설명자가 준비될 때까지 대기

epoll을 고려하여 새 서버 코드를 참조하십시오.

 

이전 코드의 배열active_fds 여기에서 제거되므로 이 서버가 실행할 수 있는 비동기 호출 수에는 이러한 제한이 없습니다.

하나씩 코드를 살펴 보겠습니다

    int epfd, ready, j;
    struct epoll_event ev;
    struct epoll_event evlist[MAX_EVENTS];
    epfd = epoll_create(5000);
    if (epfd == -1)
        printf("epoll_create");

위의 스 니펫에서 epoll_create 호출되어 epoll 인스턴스를 만들고이 인스턴스에 액세스하기 위해 파일 설명자를 반환합니다. 이 메서드의 인수는 size이며, 이는 모니터링해야 하는 파일 설명자 수에 대한 힌트입니다(일반적으로 무시됨). 나머지 데이터 구조, 즉 관심있는 파일 디스크립터와 관련된 정보를 저장하기 위해 나중에 사용되며 유사하게 모니터링 할 파일 디스크립터 및 이벤트를 지정합니다.evlistev

새로운 연결이 올 때마다, 우리는 아래와 같이 해당 소켓 파일 디스크립터를 epoll 인스턴스에 추가합니다 :

        ev.data.fd = afd;
        ev.events = EPOLLIN;
        if (epoll_ctl(epfd, EPOLL_CTL_ADD, afd, &ev) == -1)
            printf("epoll_ctl add failed");

위에서 볼 수 있듯이 accept 에 의한 파일 설명자 반환은 ev.data.fd에 저장되고 ev.events는 관심 있는 이벤트를 지정합니다.

EPOLLIN 연결된 파일 설명자를 읽기 작업에 사용할 수 있을 때 이벤트가 발생합니다. 자세한 내용은 을 참조하십시오. 이것은 소켓에서 읽기 작업을 수행 할 때 관심있는 이벤트입니다 (쓰기 작업은 단순화를 위해 다루지 않지만 EPOLLOUT 이벤트로 수행 할 수 있음)man 2 epoll_ctl

EPOLLIN - 연관된 파일을 읽기(2) 작업에 사용할 수 있습니다.

EPOLLOUT - 관련 파일을 쓰기(2) 작업에 사용할 수 있습니다.

EPOLLRDHUP-Stream 소켓 피어가 연결을 닫았거나 연결의 절반 쓰기를 종료했습니다.

EPOLLPRI - 파일 디스크립터에 예외적인 조건이 있습니다. poll(2)에서 POLLPRI에 대한 논의를 참조하십시오.

EPOLLERR - 연관된 파일 디스크립터에서 오류 조건이 발생했습니다. 이 이벤트는 읽기 끝이 닫힌 경우 파이프의 쓰기 끝에 대해서도 보고됩니다.

EPOLLHUP-Hang up이 관련 파일 설명자에서 발생했습니다.

— 에 따라 사용 가능한 이벤트 man 2 epoll_ctl

epoll_ctl는 모니터링을 위해 이 새 이벤트 및 관련 파일 설명자를 추가(EPOLL_CTL_ADD 포함)하는 데 사용됩니다.

이것은 실제 일이 일어나는 곳입니다.

ready = epoll_wait(epfd, evlist, MAX_EVENTS, 0.01);

이 함수가 호출되면 epoll은 등록 된 파일 설명자가 준비 될 때까지 기다립니다 (마지막 인수는 시간 초과, -1은 무한대까지 대기를 의미합니다. 여기서 시간 초과는 0.01이므로 해당 시간까지 준비되는 모든 파일 설명자를 반환합니다). ready 변수는 IO에 사용할 준비가 된 파일 설명자의 수를 저장합니다. evlist는 이러한 활성 파일 설명자에 대한 정보를 저장하는 데 사용됩니다. 이 정보가 있으면 반복할 수 있습니다.

for (j = 0; j < ready; j++)
{
    if (evlist[j].events & EPOLLIN)
    {
        int ready_afd = evlist[j].data.fd;
        process_client(ready_afd);
        if (epoll_ctl(epfd, EPOLL_CTL_DEL, ready_afd, NULL) == -1)
            printf("epoll_ctl remove failed");
        close(ready_afd);
        count--;
        printf("Total processed messages %d\n", total_processed);
    }
}

해당 처리는 준비된 파일 설명자에서 수행되며 일단 사용되면 epoll의 관심 있는 파일 설명자 목록에서 삭제해야 합니다. 이 삭제는 매개 변수를 사용하여 epoll_ctl 다시 호출하여 수행됩니다.EPOLL_CTL_DEL

C100k 문제에 대해 위의 프로그램을 컴파일하고 실행해 보겠습니다.

time seq 100000 | xargs -I {} -P 0 sh -c './cl'

타이밍 결과는 정말 멋지다. 100분 이내에 100k 요청을 실행합니다. 나는 그것이 진정한 4k 동시 연결 (코드를 실행중인 VM, 거의 <> 개의 vCPU를 가지고 있지 않음)이 아니라는 것을 알고 있지만, 요점은 이것이 우리가 시작하는 것보다 훨씬 낫다는 것입니다.

 

결론

이 기사에서는 비동기 IO 프로그래밍을 살펴보았고 명백한 성능 향상을 보는 것 외에도 콜백 함수가 작동하는 것을 엿볼 수 있었습니다. 이 콜백 함수는 IO가 준비될 때마다 호출되며, 다른 고급 언어에서 볼 수 있는 것과 똑같이 하위 수준 시스템 API에서 볼 수 있습니다. 우리는 약간의 노력을 기울이고 사이드 루프에서 위의 논리를 다음과 같이 다시 작성할 수 있습니다.process_client

run_handler(socket_IO, callback_function)

이렇게 하면 소켓 IO 작업의 다양한 사용 사례를 쉽게 채울 수 있습니다. 콜백 함수를 등록하고 루프를 실행하기만 하면 됩니다(메인 루프 while(1) 참조). 이것은 이벤트 루프가 구축되는 디딤돌이 될 것이며, 이는 인터넷상의 고성능 소프트웨어 (nodejs, nginx 등)의 대부분을 지원합니다.

다음

계속해서 libuv (https://github.com/libuv/libuv)를 복제하십시오. epoll_wait 검색합니다. 그 시점에서 무슨 일이 일어나고 있는지 정확히 알고 있기 때문에 거기에서 역추적합니다.

아래의 이벤트 루프 순서도를 보십시오.

이 기사에서 폴링 I/O 내부에 대해 논의했기 때문에 대부분의 것이 이해가 될 것이며, 이는 나머지를 탐색할 수 있는 좋은 기반을 제공합니다.

참조:

  1. 리눅스 프로그래밍 인터페이스(Michael Kerrisk)
  2. 매뉴얼 페이지
  3. epoll의 예: https://web.archive.org/web/20120504033548/https://banu.com/blog/2/how-to-use-epoll-a-complete-example-in-c/
  4. 리눅스 소스 코드
  5. Libuv 문서 및 소스 코드
728x90