콜백함수
- 함수를 변수처럼 사용하는 개념
- 분기문으로 다른 로직을 담긴 함수들을 선언하여 처리하기 어려운 상황에 유용
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()를 호출하여 파일을 읽는 상황이라면 시스템 콜을 운영체제에 보내고 운영체제는 호출 스레드를 일시 중지 시키고 커널이 디스크 내용을 읽어오면 호출 스레드가 재시작됨
- 블로킹 입출력
- 이처럼 스레드가 여러개여도 동기일 수 있음
- 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를 사용할 수 있도록 한다는 점에서는 논블로킹처럼 보이기도 함. 하지만 개념적으로 스레드가 기다린다는 측면에서 "블로킹"이 맞음
- cf) 스레드는 I/O 요청을 기다리는 동안 블로킹 상태로 있게 되지만, 시스템은 다른 스레드가 CPU를 활용할 수 있도록 CPU 제어권을 넘기기 때문에 스레드 차원에서의 블로킹은 맞고, 시스템 차원에서 다른 스레드가 CPU를 사용할 수 있도록 한다는 점에서는 논블로킹처럼 보이기도 함. 하지만 개념적으로 스레드가 기다린다는 측면에서 "블로킹"이 맞음
- cf) 디스크 입출력 행위는 cpu가 처리하지 않음
- cpu제어권을 상실했다가 되찾는 시간 동안 스레드나 프로세스는 블로킹되어 일시 중지됨 (스레드 양도)
- 시간이 많이 걸리는 입출력 작업이 포함될 때 가끔 호출 스레드가 블로킹되어 일시 중지되는 일이 발생람
- 호출 스레드가 블로킹되지 않고 입출력 작업처리하는 방법 : 논블로킹 사용
- 디스크 예시
- 논블로킹과 비동기 입출력 - 호출 후 함수 즉시 반환
- 네트워크 데이터 수신 예시
- 네트워크 수신 함수 recv가 논블로킹이면 이 함수를 호출할 때 운영체제는 호출 스레드를 일시 중지시키는 대신 recv 함수 즉시 반환
- 호출 스레드는 자신의 작업을 계속하며 데이터 수신 작업은 커널이 진행
- 네트워크 데이터 언제 수신했는지 어떻게 알 수 있을까?? : 세가지 방법
- 결과 확인 함수 제공
- 알림 작동 방식
- 수신처리 콜백함수를 recv인자로 전달
- 네트워크 수신 함수 recv가 논블로킹이면 이 함수를 호출할 때 운영체제는 호출 스레드를 일시 중지시키는 대신 recv 함수 즉시 반환
- 이것이 논블로킹 호출
- 이런 유형의 입출력 작업이 비동기 입출력
- 네트워크 데이터 수신 예시
동기와 블로킹, 비동기와 논블로킹
- 동기와 블로킹
- 동기 호출이 항상 블로킹인 것은 아님
- 블로킹은 모두 동기 호출임
- funcA함수가 sum함수를 호출했다고 블로킹되거나 스레드가 일시중지 되지는 않음
- 비동기와 논블로킹
- recv함수에 비동기 콜백함수 전달한 것은 논블로킹이면서 비동기
- recv함수 호출 후 check함수로 계속해서 결과 감지한다면 논블로킹이지만 동기
높은 동시성과 고성능을 갖춘 서버 구현
- 다중 프로세스
- 가장 먼저 출현한 기술
- 간단한 형태의 병행처리방식 일종인 다중 프로세스
- 프로세스간 통신에 난이도
- 프로세스 생성 종료 성능 부담
- 다중 스레드
- 프로세스 주소 공간 공유하기 때문에 리소스 공유에 별도 통신 필요 없음
- 스레드 생성 종료 부담 적음
- 각 요청에 대응하는 스레드 생성 가능
- 프로세스 주소공간 공유에 따른 단점 존재
- 주소공간 공유하기 때문에 하나의 스레드 강제 종료 시 같은 프로세스 공유하는 모든 스레드와 프로세스 강제 종료됨
- 여러 스레드가 동시에 공유 리소스 읽고 쓸 수 없음
- 다중 스레드 문제
- 스레드 안전 문제 고려 프로그래밍
- 가볍긴 하나 초당 수십만 건 요청마다 스레드 생성 정도는 성능 이슈 발생
- 메모리 소비
- 스레드 전환
- 이벤트 순환(이벤트 루프)과 이벤트 구동
- 다중 프로세스, 다중 스레드에 이은 또다른 병렬처리의 방식
- 이벤트 기반의 동시성을 이용한 이벤트 기반 프로그래밍
- 이벤트와 이벤트 처리함수
- 이벤트
- 서버에서 다루는 이벤트는 대부분 입출력
- 네트워크 데이터 수신 여부, 파일의 읽기 및 쓰기 여부 등
- 서버에서 다루는 이벤트는 대부분 입출력
- 이벤트 처리함수
- 일반적으로 이벤트 핸들러
- 이벤트 순환
- 서버에서 이벤트는 사용자 요청이며, 이벤트를 계속 수신하고 처리해야함
- 반복문 사용
while(true){
event = getEvent();
handler(event);
}
- 이벤트 순환 문제 두가지
- 문제1 - getEvent() 같은 함수 하나로 어떻게 이벤트를 가져올지??
- 입출력 다중화 기술로 해결
- 문제 2 - handler() 함수가 이벤트 순환과 동일한 스레드에서 실행되어야할지??
- 문제1 - getEvent() 같은 함수 하나로 어떻게 이벤트를 가져올지??
- 문제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는 네트워크 설정, 데이터 전송, 데이터 분석 등 지루한 작업을 담아 프로그래머가 일반함수를 호출하는 것처럼 네트워크로 통신할 수 있도록 처리함
- 예시) 서버는 일반적으로 원격 프로시저 호출, 즉 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시간을 사용자 상태에서 재차 할당하는 것
- 사용자 상태에서 할당되므로 코루틴을 사용자 상태 스레드라고도 함
'컴퓨터 과학 > [책] 컴퓨터 밑바닥의 비밀' 카테고리의 다른 글
컴퓨터 맨 밑바닥의 비밀 - 2. 운영체제, 프로세스, 스레드, 코루틴 (1) | 2024.10.25 |
---|