컴퓨터 과학/[책] 컴퓨터 밑바닥의 비밀

컴퓨터 맨 밑바닥의 비밀 - 2.1 콜백함수, 동기화, 비동기화, 블로킹, 논블로킹

꾸준함의 미더덕 2024. 11. 8. 06:42

콜백함수

  • 함수를 변수처럼 사용하는 개념
    • 분기문으로 다른 로직을 담긴 함수들을 선언하여 처리하기 어려운 상황에 유용
void make_donut(func f){
    //..
    f();
    //..
}
  • make_donut 함수를 사용하고 싶은 프로그래머는 자신이 정의한 현지화 함수를 전달
    • 위의 함수 변수를 콜백 함수라고 함
    • 일반적으로 인자로 사용되는 콜백 함수는 호출되는 함수를 사용하는 개발자가 작성
      • 따라서 콜백함수를 호출하는 것은 보통 다른 모듈 혹은 스레드

 

  • 비동기 콜백
    • 콜백 함수의 처리 속도가 오래걸리는 상황인데 이를 기다려줄 수 없다면?
// 호출 스레드
make_donut(formed_D); // 아래 작업을 기다려줄 수 없을 때 -> 비동기 콜백함수를 사용하기
something_important(); // 기다려줄 수 없는 작업..

  • 함수 내부에서 스레드를 생성하고 새로운 스레드가 콜백함수를 처리하도록 하기
void make_donut(func f){
    //별도 생성된 스레드가 콜백함수를 처리
    thread t(real_make_donut, f)
}
void real_make_donut(func f){
    //..
    f();
    //..
}
  • 주의할 점은 something_important()가 실행될 때 도넛 생성 작업은 아직 시작되지 않았을 수도 있다는 점
    • 이것이 바로 비동기
  • 이와같이 호출 스레드가 콜백 함수 실행에 의존하지 않는 것을 비동기 콜백이라고 함
request(handle); //handle 콜백함수를 비동기로 처리할 때
// -> request를 실행한 스레드와 handle을 실행한 스레드는 별개다

 

  • 비동기 호출 프로그래밍 방식은 작업처리가 두 부분으로 나뉨
    • 어떤 일을 해야하는지 알지만 언제 하게 될지 알 수 없음
      • request를 호출하고 handle을 넘겨주는 작업 (handle을 정의했지만 비동기로 실행되기 때문에)
    • 언제 해야할지는 알지만 무엇을 해야하는지 모름
      • request 내부에서 처리되는 작업 (request 즉시 실행되지만 내부가 캡슐화 되었으므로)

  • 콜백함수의 정의
    • 다른 코드에 매개변수로 전달되는 실행 가능한 코드
    • 콜백함수와 주 프로그램은 같은 계층에 있지만 해당 콜백함수를 작성만하지 직접 호출하지 않는다는 것
  • 콜백함수가 호출되는 시점
    • 특정 이벤트가 발생하고 이를 처리할 수 있는 코드를 호출할 때 콜백함수가 유용
      • 이 관점에서 콜백함수는 이벤트 핸들러
      • 콜백함수로 전달되는 이벤트 핸들러는 보통 비동기 콜백임
        • 동기 콜백이면 이벤트 발행될 때까지 다른 작업 불가.. (while을 돌며 이벤트 발행여부를 확인 하고 있으므로..)

 

  • 동기 콜백 vs 비동기 콜백
    • 비동기 콜백은 다중 코어 리소스를 더 잘 활용가능
    • 비동기 콜백은 파일 입출력 작업, 웹서비스처럼 동시성이 높은 시나리오에 적합
  • 비동기 콜백의 문제
    • 콜백 지옥
      • 비즈니스가 복잡한 경우 콜백 지옥에 빠질 가능성이 높음
      • 비동기 콜백의 효율과 동기 콜백의 단순성을 함께 누리는 방법이 코루틴

동기 vs 비동기

  • 동기 호출은 보통 같은 스레드 내에서 진행
    • 파일 입출력은 동기지만 다른 스레드에서 진행됨
      • read()를 호출하여 파일을 읽는 상황이라면 시스템 콜을 운영체제에 보내고 운영체제는 호출 스레드를 일시 중지 시키고 커널이 디스크 내용을 읽어오면 호출 스레드가 재시작됨
        • 블로킹 입출력
        • 이처럼 스레드가 여러개여도 동기일 수 있음
  • 비동기 호출은 보통 시간이 많이 걸리는 입출력 작업을 백그라운드 형태로 실행
    • 파일 읽고 쓰기, 네트워크 데이터 송수신, 데이터베이스 작업 등
  • 비동기 호출 방식에서 작업이 실제로 완료되는 시점을 어떻게 파악할 수 있을까??, 그 결과를 어떻게 처리해야할까??
    • 두 가지 상황이 존재
      • 호출자가 실행결과를 전혀 신경쓰지 않을 때
      • 호출자가 실행결과를 반드시 알아야 할 때
        • 알림 작동 방식

웹서버에서 동기와 비동기 작업

  • 웹서버 요청 처리 작업 중 대표적인 것이 데이터베이스 요청
    • 예시 상황
      • DB 외 입출력 없는 상황
      • A,B,C 작업
      • 데이터베이스 요청
      • D,E,F 작업
    • 일반적으로 이런 형태의 웹서버에는 주 스레드와 데이터베이스 처리 스레드가 존재
    • 동기 처리 시 주 스레드의 유휴 시간이 발생
    • 비동기 구현에선 주 스레드가 데이터베이스 요청 전송 후 바로 새로운 사용자 요청을 직접 처리
      • 주스레드가 A,B,C처리 후 데이터베이스 요청 후 새로운 사용자 요청을 받는다면 D,E,F는??
      • 두가지 상황 존재
        • 주 스레드가 데이터베이스 처리 결과를 신경쓰지 않음
          • 주 스레드가 아닌 데이터베이스 스레드가 데이터베이스 처리 후 이어서 D,E,F처리
          • 데이터베이스 스레드가 D,E,F 작업에 대해 어떻게 아는지?
            • 주 스레드에서 D,E,F를 콜백함수로 넘긴다 (데이터 베이스 호출과 함께)
        • 주 스레드가 데이터베이스 처리 결과에 관심을 가질 때
          • 데이터베이스 스레드는 알림작동 방식을 이용하여 작업 결과를 주 스레드로 전송
          • 주스레드는 메시지를 수신하면 후반부작업 처리
          • 데이터베이스 스레드가 유휴상태 (입출력 기간동안) 라는점을 제외하면 주스레드에 유휴시간은 없음
        • 데이터베이스 스레드가 후반부작업을 처리하는 것보다는 비효율적이지만 동기보다는 효율적

블로킹과 논블로킹

  • 프로그래밍에서 함수를 호출할 때 주로 사용
  • 함수A가 함수B를 호출할 때
    • B를 호출함과 동시에 운영체제가 함수A가 실행 중인 스레드나 프로세스를 일시 중지 시킨다면 블록킹
    • 아니면 논블로킹
  • 함수 호출로 인해 호출자의 스레드나 프로세스가 운영체제에 의해 일시중지 되는 것은 어떤 경우??
  • 블로킹의 핵심 문제 : 입출력 - 일반적으로 입출력 시 호출 스레드가 블로킹 됨
    • 디스크 예시
      • cf) 디스크 입출력 행위는 cpu가 처리하지 않음
        • cpu는 입출력 요청을 디스크 컨트롤러에 전달할뿐임
        • 실데 디스크 입출력 작업(데이터 읽고 메모리로 전송)은 전용 하드웨어가 처리함
      • 디스크가 하나의 트랙 탐색 입출력 요청을 완료하는데 소요되는 시간은 ms단위 수준
        • 우리 스레드에서 입출력 과정이 실행되는 동안 cpu 제어권을 다른 스레드에 넘겨 작업 (블로킹)할수 있도록 함
        • 완료되면 cpu 제어권을 우리 스레드 혹은 프로세스에서 넘겨받아 다음 작업을 실행함
          • cf) 스레드는 I/O 요청을 기다리는 동안 블로킹 상태로 있게 되지만, 시스템은 다른 스레드가 CPU를 활용할 수 있도록 CPU 제어권을 넘기기 때문에 스레드 차원에서의 블로킹은 맞고, 시스템 차원에서 다른 스레드가 CPU를 사용할 수 있도록 한다는 점에서는 논블로킹처럼 보이기도 함. 하지만 개념적으로 스레드가 기다린다는 측면에서 "블로킹"이 맞음

    • cpu제어권을 상실했다가 되찾는 시간 동안 스레드나 프로세스는 블로킹되어 일시 중지됨 (스레드 양도)
    • 시간이 많이 걸리는 입출력 작업이 포함될 때 가끔 호출 스레드가 블로킹되어 일시 중지되는 일이 발생람
      • 호출 스레드가 블로킹되지 않고 입출력 작업처리하는 방법 : 논블로킹 사용

 

  • 논블로킹과 비동기 입출력 - 호출 후 함수 즉시 반환
    • 네트워크 데이터 수신 예시
      • 네트워크 수신 함수 recv가 논블로킹이면 이 함수를 호출할 때 운영체제는 호출 스레드를 일시 중지시키는 대신 recv 함수 즉시 반환
        • 호출 스레드는 자신의 작업을 계속하며 데이터 수신 작업은 커널이 진행
      • 네트워크 데이터 언제 수신했는지 어떻게 알 수 있을까?? : 세가지 방법
        • 결과 확인 함수 제공
        • 알림 작동 방식
        • 수신처리 콜백함수를 recv인자로 전달
    • 이것이 논블로킹 호출
      • 이런 유형의 입출력 작업이 비동기 입출력

동기와 블로킹, 비동기와 논블로킹

  • 동기와 블로킹
    • 동기 호출이 항상 블로킹인 것은 아님
    • 블로킹은 모두 동기 호출임
      • funcA함수가 sum함수를 호출했다고 블로킹되거나 스레드가 일시중지 되지는 않음
  • 비동기와 논블로킹
    • recv함수에 비동기 콜백함수 전달한 것은 논블로킹이면서 비동기
    • recv함수 호출 후 check함수로 계속해서 결과 감지한다면 논블로킹이지만 동기

높은 동시성과 고성능을 갖춘 서버 구현

  • 다중 프로세스
    • 가장 먼저 출현한 기술
    • 간단한 형태의 병행처리방식 일종인 다중 프로세스
      • 프로세스간 통신에 난이도
      • 프로세스 생성 종료 성능 부담
  • 다중 스레드
    • 프로세스 주소 공간 공유하기 때문에 리소스 공유에 별도 통신 필요 없음
    • 스레드 생성 종료 부담 적음
    • 각 요청에 대응하는 스레드 생성 가능
    • 프로세스 주소공간 공유에 따른 단점 존재
      • 주소공간 공유하기 때문에 하나의 스레드 강제 종료 시 같은 프로세스 공유하는 모든 스레드와 프로세스 강제 종료
      • 여러 스레드가 동시에 공유 리소스 읽고 쓸 수 없음
    • 다중 스레드 문제
      • 스레드 안전 문제 고려 프로그래밍
      • 가볍긴 하나 초당 수십만 건 요청마다 스레드 생성 정도는 성능 이슈 발생
        • 메모리 소비
        • 스레드 전환

  • 이벤트 순환(이벤트 루프)과 이벤트 구동
    • 다중 프로세스, 다중 스레드에 이은 또다른 병렬처리의 방식
    • 이벤트 기반의 동시성을 이용한 이벤트 기반 프로그래밍
    • 이벤트와 이벤트 처리함수
  • 이벤트
    • 서버에서 다루는 이벤트는 대부분 입출력
      • 네트워크 데이터 수신 여부, 파일의 읽기 및 쓰기 여부 등
  • 이벤트 처리함수
    • 일반적으로 이벤트 핸들러
  • 이벤트 순환
    • 서버에서 이벤트는 사용자 요청이며, 이벤트를 계속 수신하고 처리해야함
    • 반복문 사용
while(true){
    event = getEvent();
    handler(event);
}

 

  • 이벤트 순환 문제 두가지
    • 문제1 - getEvent() 같은 함수 하나로 어떻게 이벤트를 가져올지??
      • 입출력 다중화 기술로 해결
    • 문제 2 - handler() 함수가 이벤트 순환과 동일한 스레드에서 실행되어야할지??

 

 

  • 문제1 해결 방법 - 이벤트 소스와 입출력 다중화
    • 리눅스와 유닉스 세계에서 모든 것은 파일로 취급됨!
    • 프로그램은 모두 파일 디스크립터를 사용하여 입출력 작업을 실행
      • 소켓도 예외 아님
    • 예시 - 사용자 연결이 열개이고, 이에 대응하는 소켓 디스크립터가 열개 있는 서버가 데이터를 수신하려고 대기 중인 상횡
      • 가장 간단한 예시는 아래
      • 첫번째 사용자가 데이터를 보내지 않는 한 recv(fd1, buf1)는 반환되지 않으므로 서버가 두번째 사용자의 데이터를 수신 및 처리 불가..
	recv(fd1, buf1);
	recv(fd2, buf2);
	recv(fd3, buf3);
	//..

 

    • 더 나은 작동 방식은 운영체제에 ‘소켓 디스크립터 열개 감시하고 있다가 데이터가 들어오면 알려줘’ 라는 내용 전달하는 작동 방식 사용
      • 이런 작동 방식을 입출력 다중화라함
        • 입출력 다중화는 이벤트 순환 엔진이 된다
      • 리눅스에서 유명한 것이 epoll
// 이벤트 순환을 위한 epoll 생성
epoll_fd = epoll_create(); //getEvent 동일

// 서술자를 epoll이 처리하도록 지정
Epoll_ctl(epoll_fd, fd1, fd2, fd3, fd4...);

while (1)
{
    int n = epoll_wait(epoll_fd);

    for (i = 0; i < n; i++)
    {
        // 특정 이벤트 처리
    }
}

 

    • 두번째 문제 : 이벤트 순환과 다중 이벤트
    • 이벤트 순환과 이벤트 핸들러 단일 스레드 예시
      • 이벤트 핸들러에서
        • 입출력 작업이 없고
        • 처리시간이 짧을 때
      • 이러한 상황은 이벤트 순환과 이벤트 핸들러가 같은 스레드에서 실행되도 문제 없음
      • 하지만 사용자 요청을 처리하는데 cpu가 많은 시간을 소모한다면??
        • 요청A를 처리하는 중에 요청 B를 처리할 수 없음

 

  • 문제2 해결 방법 - 다중 스레드 (반응자 패턴)
    • 이벤트 순환과 이벤트 핸들러가 다른 스레드
    • 요청 처리속도를 높이고 다중 코어 최대한 활용
      • 이벤트 순환은 요청을 수신하면 간단한 처리 후 바로 각각의 작업자 스레드에 분배
      • 작업자 스레드를 스레드 풀로 관리 가능
    • 이 설계 방법은 반응자 패턴이라는 이름

 

  • 이벤트 순환과 입출력
    • 요청 처리 과정에서 입출력도 존재하는 경우에서 두가지 상황
    • 1. 입출력 작업에 대응하는 논블로킹 인터페이스가 있는 경우
      • 논블로킹 인터페이스를 호출해도 스레드가 일시중지되지 않으며 인터페이스가 즉시 반환되므로 이벤트 순환에서 직접 호출이 가능
    • 2. 입출력 작업에 블로킹 인터페이스만 있는 경우 - 이벤트 순환 내부에서 블록킹 호출하면 안됨
      • 이벤트 순환내에서 절대로 블로킹 인터페이스를 호출해선 안됨
      • 이벤트 순환 스레드가 일시중지 될 수 있음
      • 블로킹 입출력 호출이 포함된 작업은 작업자 스레드에 전달해야함!

  • 비동기와 콜백함수
    • 예시) 서버는 일반적으로 원격 프로시저 호출, 즉 RPC를 통해 통신함
      • RPC는 네트워크 설정, 데이터 전송, 데이터 분석 등 지루한 작업을 담아 프로그래머가 일반함수를 호출하는 것처럼 네트워크로 통신할 수 있도록 처리함
void handler(request) {
    A;
    B;
    GetUserInfo(request, response);  // 서버 A에 요청
    C;
    D;
    GetQueryInfo(request, response); // 서버 B에 요청
    E;
    F;
    GetStorkInfo(request, response); // 서버 C에 요청
    G;
    H;
}
  • Get으로 시작하는 RPC호출은 모두 블로킹 호출이기 때문에 사용자가 응답하기 전에는 함수가 반환되지 않음
    • 블로킹 호출이기 때문에 스레드가 일시중지 될 수 있고 블로킹 호출이 여러번 발생하면 스레드 빈번하게 중단되어 cpu 비효율적임
  • 더 나은 방식은 동기 방식의 RPC 호출을 비동기 호출로 바꾸기
    • 처리할 내용을 콜백함수에 담아 RPC 호출하기
void handler_after_GetStorkInfo(response) {
    G;
    H;
}

void handler_after_GetQueryInfo(response) {
    E;
    F;
    GetStorkInfo(request, handler_after_GetStorkInfo); // 서버 C에 요청
}

void handler_after_GetUserInfo(response) {
    C;
    D;
    GetQueryInfo(request, handler_after_GetQueryInfo); // 서버 B에 요청
}

void handler(request) {
    A;
    B;
    GetUserInfo(request, handler_after_GetUserInfo);   // 서버 A에 요청
}
  • 주 프로세스는 네 개로 분할되었고 콜백안에 콜백에 포함되게 됨
    • 사용자 서비스가 더 많아지면 이런 형태의 코드는 관리가 불가능
  • 비동기 프로그램의 효율성과 동기 프로그램의 단순성 조합??
    • 코루틴

  • 코루틴 : 동기 방식의 비동기 프로그래밍
    • 프로그래밍 언어나 프레임워크가 코루틴을 지원하면 아래와 같이 handler 함수가 코루틴에서 실행되도록 할 수 있음 
    • void handler() { A; //호출 진입점 B; GetUserInfo(); //연결시작지점 반환 C; //호출 진입점 D; GetQueryInfo(); //연결시작지점 반환 E; //호출 진입점 F; GetStorkInfo(); //연결시작지점 반환 G; H; //반환 }

 

  • handler 함수의 코드구현은 동기
  • 하지만 yield로 cpu 제어권을 반환하는 등 RPC 통신이 시작된 후 적극적으로 바로 호출된다는 점이 다르다
    • 이때 RPC 호출함수나 네트워크 데이터 전송 같이 기존 함수를 수정해야 yield로 cpu 제어권 반환할 수 있다는 점 유의
  • 가장 중요한 점은 코루틴이 일시 중지 되더라도 작업자 스레드가 블로킹 되지 않는다는 점
    • 코루틴과 스레드를 사용하는 블로킹 호출의 가장 큰 차이점
  • 코루틴 작동 방식
    • 코루틴이 일시중지되면
    • 작업자 스레드는 준비 완료된 다른 코루틴을 실행하기 위해 전환되며
    • 일시 중지된 코루틴에 할당된 사용자 서비스가 응답한 후 그 처리결과를 반환하면
    • 다시 준비 상태가 되어 스케줄링 상태가 돌아오길 기다림
    • 이후 코루틴은 마지막으로 중지되었던 곳에서 이어서 계속 실행
  • 코루틴의 도움으로 동기 방식 프로그래밍하더라도 비동기 실행과 같은 효과를 얻을 수 있게됨

 

  • 반응자 패턴에 코루틴 적용 작동 방식
    • 이벤트 순환은 요청을 받은 후
    • 우리가 구현한 handler 함수를 코루틴에 담아 스케줄링과 실행을 위해 각 작업자 스레드에 배포함
    • 작업자 스레드는 코루틴을 획득한 후 진입 함수인 handler를 실행하기 시작
    • 어떤 코루틴이 능동적으로 cpu 제어권를 반환하면
    • 작업자 스레드는 준비 상태인 다른 코루틴을실행
  • 코루틴이 블로킹 방식으로 RPC를 호출하더라도 작업자 스레드는 블로킹되지 않기 때문에 시스템 리소스를 효율적으로 사용

  • cf) 스레드 vs 코루틴 비교
    • 스레드 실행정보는 스택 영역에서 따로 관리 되어 다른 스레드가 해당 정보를 이용하기 어려움
      • 스레드는 운영체제가 자동으로 관리하고 스케줄링해주는 장점이 있음
    • 코루틴 실행정보는 힙 영역에서 공유될 수 있으므로 가능하여 다른 스레드가 이어서 작업 용이
      • 코루틴은 프레임워크가 관리해주는 측면이 있지만, 비동기 처리와 관련된 설계 및 제어를 개발자가 신경 써야 하는 부분이 있어 작성시 고려해야함

  • CPU, 스레드, 코루틴 관계

 

  • CPU
    • 기계명령어를 실행하여 컴퓨터를 움직이게 함
  • 스레드
    • 일반적으로 커널 상태 스레드
    • 커널로 생성되고 스케줄링을 함
    • 커널은 스레드 우선순위에 따라 CPU 연산 리소스를 할당함
  • 코루틴
    • 커널 입장에서 코루틴을 모름
    • 코루틴이 얼마나 많이 생성되었든 커널은 이와 관계 없이 스레드에 따라 CPU 시간을 할당
    • 개발자는 스레드에 할당된 시간 내 실행할 코루틴을 결정할 수 있음
      • 스레드에 할당된 CPU시간을 사용자 상태에서 재차 할당하는 것
      • 사용자 상태에서 할당되므로 코루틴을 사용자 상태 스레드라고도 함