학교 강의시간에 한시간 정도 동기·비동기에 대해 이야기를 했다. 그런데 내용이 대부분 뜬 구름을 잡는 소리고, 추상적인 개념만을 다뤘다. 인터넷을 검색해도 말끔하게 전체를 다루는 글이 없거나 내가 찾기엔 어려워 보였다. 그래서 동기·비동기에 대해 한번 다뤄보고자 한다. 이 글은 리눅스에서의 네트워크 IO를 기준으로 작성한다. 그런 점에서, 다음 글을 읽어보면 좋을것이다: https://blog.packagecloud.io/monitoring-tuning-linux-networking-stack-receiving-data/, https://blog.packagecloud.io/monitoring-tuning-linux-networking-stack-sending-data/. 각각 리눅스에서 네트워크에서 데이터를 수신 및 처리하는 방법과 발신 처리하는 방법에 대해 다룬 글이다. 이 글도 해당 글에서 영향을 많이 받았다.

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

우선 blocking과 non-blocking을 알아보자.

아마 프로그래밍을 공부하면서 입력을 받거나, 파일을 읽거나, 소켓을 읽는등의 작업을 한 적이 있을테다. 입력을 받는것의 가장 간단한 형태는 다음과 같다

int main() {
	int input; 
	int len = scanf("%d", &input); 

	printf("%d (%d)", input, len);
}

이 코드는 [숫자 하나를 입력 받은 후] → [<입력 받은 숫자>와 <입력된 글자수>를 출력]하는 코드이다. 이 프로그램은 scanf 함수가 실행될 때 입력이 완료될 때 까지 프로그램이 멈춘다. 간단히 이것이 blocking이다. 이것이 파일이라면 다음 블록을 읽을때 까지, 소켓이라면 데이터가 수신 될 때까지 프로그램이 대기 상태에 빠진다. (이때는 read 함수를 사용하겠지만) 입력이 완료된 후에 printf 를 통해서 숫자를 출력한다.

Untitled

단순한 프로그램을 만들때는 블로킹 방식이 편하다. 프로그램의 실행을 예측할 수 있다. scanf가 완료되어야지 printf가 실행된다. 사실 옛날의 프로그램들은 거의 이런 방식을 사용했다.

그러나 이 코드에는 하나의 단점이 있다. scanf 가 완료 될 때 까지 프로그램이 멈춘다는 것이다. 만약 서버 프로그램을 만든다고 생각하자. 동시에 여러곳에서 접속하고 처리할 수 있는 프로그램을 만들고 싶을것이다. 이 구조에서는 불가능한 일이다. 상대측이 데이터를 보내줄 때 까지 무한대기를 하기 때문이다. 상대방이 데이터를 보내지 않으면 꼼짝없이 기다려야 한다.

htop을 실행했을때의 모습. scanf 지점에서 sleep이 걸린것을 볼 수 있다. (S = S [Sleeping])

htop을 실행했을때의 모습. scanf 지점에서 sleep이 걸린것을 볼 수 있다. (S = S [Sleeping])

이런 블로킹 구조를 가지면서 동시 처리를 하기 위해 Thread를 활용하기 시작했다. Thread는 각자의 실행 흐름을 가진다. 즉, scanf 를 동시에 여러개 실행할 수 있다. (동시에 실행한다... 는 엄밀하게 말하면 아니긴 하다.) 쓰래드를 100개 만들면 100개의 실행 흐름을 관리할 수 있다. 1000개를 만들면 마찬가지로 1000개의 실행흐름을 관리할 수 있다. read(scanf) 호출에서 멈춘 쓰래드는 멈춘채로 남아있다. 하지만 다른 쓰레드는 작동하기 때문에 마치 동시에 실행되는것 처럼 느낄 수 있다.

Untitled

사람으로 비유하면 이해하기 쉽다. 프로그램을 하나의 기업이라고 생각하자. 그리고 쓰레드를 각각의 사원이라고 생각하자. 각각의 사원들은 거래처에서 자신에게 보내주는 이메일을 읽고 처리를 한다. 한명의 사원은 하나의 거래처만을 담당한다. 이때 본인에게 이메일이 오지 않는다면 해당 사원은 할 일 없이 기다려야 한다. 이것이 read(scanf) 호출과 같다. 자신이 처리할 데이터가 없다면... 데이터가 생길때 까지 기다려야 한다. (다른 이메일 까지 확인하는것은 곧 나올 다른 방법을 사용해야 한다)

다른 거래처에서 요청 메일을 보내도 딱히 방법이 없다. 사원 한명이 특정한 거래처(파일 또는 EndPoint)만을 담당하기 때문이다. 이런 구조에서는 여러 거래처의 메일을 처리하려면 (동시성을 가지려면) 사원을 더 늘릴 수 밖에 없다. Thread를 늘릴수 밖에 없다.

조금 더 안으로...

read 호출은 어떻게 키보드 입력을 알아낼까? 현대의 컴퓨터는 IO장치의 작동을 인터럽트를 통해 처리한다. (큰 데이터를 처리할 경우에는 DMA가 포함되기도 한다.) 특정한 키(E)를 눌렸다고 하자. 키를 누른 순간 USB(또는 PS2)에 대한 인터럽트가 발생한다. 인터럽트가 발생하면 CPU는 원래 하던 작업을 멈추고, [Interrupt table(Vector)에 설정되어 있는 주소]에 있는 코드를 실행한다. 일단 부팅이 완료된 상태면 Interrupt Vector가 커널과 연결된 상태이다.(부팅 과정에서 커널의 헨들러로 설정된다) 그래서 커널이 특정 인터럽트가 발생했음을 인지하고 적절한 처리를 한다.

인터럽트는 원래 하는 작업을 강제로 멈춘다. 인터럽트 헨들러가 복잡하다면 인터럽트를 처리하는데만 CPU를 다 쓸수도 있다. 그래서 중요한 인터럽트가 아니라면 kernel에 인터럽트의 발생을 기록만 하고, 추후 kernel time때 인터럽트를 처리하기도 한다.

<aside> 🔔 원래 하는 작업을 강제로 멈춘다는 점이 나쁘게 보일수도 있다. 하지만 이 특성은 중요하며 매우 잘 쓰인다. 이 특성을 통해 4ms 마다 process switching이 이루어진다. 시스템 타이머의 존재 덕분이다. 우리가 프로그램을 돌리면 마치 모든 프로그램이 동시에 작동하는것 처럼 느껴진다. 그 이유가 인터럽트 덕분이다. 시스템 타이머가 인터럽트를 통해 tick(시간이 흐름)을 알린다. 인터럽트가 발생하면 강제로 CPU가 커널의 헨들러를 실행한다. 여기서 필요한 작업을 하고, (제너릭 리눅스 기준으로) 4ms가 흘렀다고 판단되면 _schedule 을 통해서 다른 프로세스에게 명령을 넘겨준다. (만약 어셈블리를 접해본 적 있다면... 어셈블리 코드 상에서는 커널로 작업권을 넘겨주는게 없다 - 시스템콜을 위한 인터럽트 제외- 그럼에도 커널이 작업을 받아오는것은 타이머 인터럽트 덕분이다.) - https://elixir.bootlin.com/linux/v5.3.8/source/arch/x86/kernel/time.c#L59

</aside>

프로그램이 인터럽트를 때릴수도 있다. 생각해 보면 read 는 시스템 콜이다. 즉 커널을 통해서 수행된다. 프로그램이 CPU를 커널에게 넘겨줄 필요가 생긴 것이다. 이때 사용되는것도 인터럽트이다. (하드웨어적 인터럽트와 구분하기 위해 소프트 인터럽트라고 한다.) 프로그램이 인터럽트를 때린 셈이다. 레지스터에 적절한 값을 채우고 int 80h 명령어를 실행하면 커널이 이어 받아서 처리한다. Interrupt가 발생하면 커널의 지정된 핸들러가 실행되서 가능하다. read 를 실행하는것은 사실 인터럽트를 통해 커널로 특정 기능을 실행한 것이다.

“읽기” 요청은 당연하게 “읽을게 생겨야지” 수행이 가능하다. “읽을것이 없으면” 프로그램이 실행될 수 없다. 인터럽트를 통해서 읽기 요청을 받으면 커널은 더 이상 프로그램(멀티 쓰레드라면 해당 쓰레드)에게 실행권을 주지 않는다. 스케쥴링시에 고려 대상에 넣지 않는다. 그러므로 사용자 관점에서 “대기 상태로 빠진것” 으로 보인다.