일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- vmalloc
- Android
- kmalloc
- slowpath
- Kernel
- Apache
- fastpath
- slub
- blk-mq
- pmap
- slab
- commit
- lruvec
- kafka
- 카프카
- buddy_system
- proc
- allocator
- Linux
- strex
- multiqueue
- Network
- devicedriver
- NDK
- vm_area_struct
- mm_struct
- memory
- spinlock
- page
- BLOCK
- Today
- Total
Art of Pr0gr4m
블로킹, 논블로킹, 동기, 비동기 비교 본문
결론부터 이야기하면 동기 IO와 비동기 IO는 'IO 시작 후 완료 전에 다른 작업을 수행할 수 있는지'와 '요청과 완료 순서의 보장'으로 구분할 수 있다. 블로킹 IO와 논블로킹 IO는 레이어(커널, 시스템콜, 특정 언어 및 라이브러리)마다 의미가 조금씩 달라지는데, 시스템 콜에서는 동기 IO 내에 블로킹 모드와 논블로킹 모드가 존재한다. 우선 시스템콜 레이어를 기준으로 해당 내용에 대해 알아보자.
블로킹 시스템콜 IO는 호출 시 해당 작업을 수행할 수 있을 때까지 반환하지 않고 대기한다.
논블로킹 시스템콜 IO는 호출 시 해당 작업을 수행할 수 없다면 바로 반환한다. (혹은 일부 가능 시 일부만 수행하고 반환한다.)
예를 들어서 디스크에 write(disk_fd, "0123456789", 10); 함수를 호출할 때,
디스크 버퍼가 10byte 이상 남은 상황, 3byte 남은 상황, 0byte 남은 (고갈된) 상황을 각각 고려해보자.
(아래 상황은 이해를 위한 단순 예시로, 실제 동작 방식은 조금 다르다는걸 염두하자.)
블로킹모드(default)
- 버퍼 10b 남음 : 바로 "0123456789"을 모두 쓰기 시작한다. 버퍼에 쓰기가 완료될 때까지 대기했다가 반환한다. (대기 시간 : 10b 쓰는 시간)
- 버퍼 3b 남음 : 우선 "012"를 쓴다. 그리고 버퍼가 비워질 때까지 기다린다. 버퍼가 비워지면 "3456789"를 마저 쓴다. 쓰기가 완료될 때까지 대기했다가 반환한다. (대기 시간 : 버퍼 7b 비워지는 시간 + 10b 쓰는 시간)
- 버퍼 0b 남음 : 버퍼가 비워질 때까지 기다린다. 버퍼가 비워지면 쓰기 시작하고, 쓰기가 완료될 때까지 대기했다가 반환한다. (대기 시간 : 버퍼 10b 비워지는 시간 + 10b 쓰는 시간)
(위 설명은 이해를 위한 예시이며, 실제로는 효율을 위해 한번에 대기했다가 한번에 쓰는 등 구현에 따라 다름)
논블로킹모드(O_NONBLOCK)
- 버퍼 10b 남음 : 바로 "0123456789"을 모두 쓰기 시작한다. 버퍼에 쓰기가 완료될 때까지 대기했다가 반환한다. (대기 시간 : 10b 쓰는 시간)
- 버퍼 3b 남음 : "012"를 쓴다. 3byte 쓰기가 완료될 때까지 대기했다가 3을 반환한다. 일부 성공. (대기 시간 : 3b 쓰는 시간)
- 버퍼 0b 남음 : 바로 0을 반환한다. (대기 시간 : 없음)
주의할 점은 논블로킹이라고 해서 아예 대기가 없는 것은 아니다. 쓰기 작업이 수행되는 동안에는 블로킹된다.
단지, 블로킹모드는 요청한 작업을 수행할 수 있을 때까지 무한정 대기하는데 논블로킹 모드는 요청한 작업을 수행할 수 없다면 반환하게 된다.
물론, 디스크 자체에 문제가 생겨서 IO가 아예 불가능한 상황이 된다면 블로킹 모드나 논블로킹 모드나 바로 에러로 반환한다. (이 때는 0을 반환하는 것이 아니라 -1을 반환함)
아무튼 블로킹 모드나 논블로킹 모드나 IO 요청 후 실제 수행하는 동안은 IO 대기 상태가 되고, IO가 완료되면 반환하는 것은 동일하다.
이러한 성격 때문에 여러 개의 IO 요청이 있을 때, 요청과 완료의 순서가 뒤바뀔 일이 없다.
즉,
write(disk_fd, "01234", 5);
write(disk_fd, "56789", 5);
를 나눠서 순서대로 호출했을 때, 절대 "56789" 쓰기가 "01234"보다 먼저 완료될 일이 없다.
이는 블로킹 모드나 논블로킹나 마찬가지다.
요청으로부터 완료되는 순서가 보장되어 있다. 다시 말해, 요청부터 완료까지의 동작들이 한 묶음으로 동기화 되어 있기 때문에 이를 동기(synchronous) IO라고 한다.
(물론, 동일한 쓰레드 내에서의 이야기다. 서로 다른 쓰레드에서 write를 호출했다면 보장되지 않는다.)
동기 IO에서는 함수 호출이 곧 요청이고, 해당 함수의 반환이 곧 완료다.
하나의 함수가 요청과 완료 통지 기능을 전부 갖추고 있다.
비동기(asynchronous) IO는 동기 IO와 다르게 일반적으로 요청과 완료가 분리된다.
비동기 IO는 IO 요청 후 작업이 완료될 때까지 대기하지 않고 바로 반환한다.
해당 작업을 수행할 수 있든 없든 상관 없이 단순 요청만 하고 반환한다. 그리고 별도의 완료 통지 메커니즘을 갖는다.
예를 들어서 디스크에 비동기 쓰기 aio_write(disk_fd, "0123456789", 10); 함수를 호출한다고 가정하자. (실제 aio_write 함수는 struct aiocb를 인자로 받아야 하지만 이해를 위해 간략화한다.)
디스크 버퍼가 얼마나 남아있는지 상관 없다. 그냥 디스크에 이거 써줘 를 요청하고, 바로 반환한다.
이후에 별도의 함수들(aio_error, aio_return)을 통해서 작업이 진행중인지, 완료되었는지, 실패했는지, 최종적인 결과가 어떻게 되었는지 등을 알 수 있다. 원한다면 작업이 완료되었을 때 비동기적으로 완료 통지(signal이나 callback)를 받을 수도 있다.
이처럼 비동기 IO는 요청과 실제 동작 및 완료가 분리되어있기 때문에, 요청 후 완료 전까지 다른 작업들을 수행할 수 있다.
동기 IO에서는 블로킹/논블로킹 상관 없이 IO 요청 후 실제 작업이 완료되기 전까지는 다른 작업을 수행할 수 없었다. 즉, IO 요청부터 완료될 때까지는 해당 IO를 요청한 쓰레드가 CPU 연산을 사용할 수 없었다.
반면 비동기 IO는 요청 후 IO와 관련 없는 CPU 연산을 얼마든지 수행할 수 있다. 실제 작업은 백그라운드에서 커널과 디바이스가 열심히 처리해준다.
이러한 비동기 IO의 동작 방식 때문에 여러 개의 IO 요청이 있을 때, 요청과 완료의 순서가 동일하도록 보장되지 않는다.
aio_write(disk_fd, "01234", 5); // 실제 인자는 aiocb 객체를 넘겨야 함
aio_write(disk_fd, "56789", 5);
를 나눠서 호출했다면 "56789" 쓰기가 "01234"보다 먼저 완료될 수 있다.
어차피 커널은 먼저 요청한걸 먼저 처리하는 것이 아니라, 여러 요청들을 모아서 효율적으로 동작할 수 있도록 알아서 잘 처리한다. 커널에 "01234" 쓰기 요청과 "56789" 쓰기 요청이 (거의) 동시에 들어왔을 때 "56789" 를 먼저 쓰는 것이 효율적이라고 판단되면 그렇게 작업을 진행한다.
앗, 그렇다면 디스크에 "0123456789"가 써지는 것을 원했는데 "5678901234"가 써질 수 있다는 것일까?
당연히 이런 문제가 발생할 수 있기 때문에 애초에 비동기 IO는 보통 어디에 작성할지 오프셋을 지정하도록 되어있다.
이처럼 비동기 IO는 요청부터 완료까지의 동작들이 한 묶음으로 동기화되지 않는다.
결국 동기 IO와 비동기 IO의 가장 큰 차이는 'IO 수행 시작 이 후 완료되기 전까지 다른 작업을 할 수 있는지 여부' 와 '요청과 완료 순서가 동일한가' 이다.
'동기는 블로킹이고, 비동기는 논블로킹이다' 는 시스템 콜 레벨에서 아예 틀린 문장임을 알 수 있다.
시스템 콜에서는 블로킹 IO나 논블로킹 IO나 모두 동기 IO이고, 비동기 IO는 이와 아예 별도의 방식이라고 할 수 있다.
시스템콜 함수에 대응한다면 read/write 함수는 동기 IO, aio_read/aio_write 함수는 비동기 IO 로 분류한다. 그리고 동기 IO인 read/write 함수는 O_NONBLOCK 세팅 여부에 따라 블로킹과 논블로킹으로 분류한다.
하지만 시스템 콜이 아닌 특정언어/라이브러리/프레임워크 레벨에서는 블로킹/논블로킹 IO를 자체적으로 정의하기도 한다. 비동기 IO는 대부분의 분야 및 레벨에서 동일한 개념으로 이야기하지만, 유독 블로킹/논블로킹은 의미가 달라지기도 한다.
예를 들어 블로킹 IO를 'IO 요청에 의해서 쓰레드의 블로킹이 조금이라도 발생하는 IO' 라는 의미로 사용하고, 논블로킹 IO를 'IO 요청에 의해서 쓰레드의 블로킹이 아예 발생하지 않는 IO'라는 의미로 사용하기도 한다. 이 경우엔 보통 구조적으로 블로킹 IO가 동기 IO가 되고, 논블로킹 IO가 비동기 IO가 될 것이다. (이 개념을 시스템콜에 적용한다면 read/write 함수는 논블로킹 모드여도 쓰레드 블로킹이 발생하기 때문에 블로킹 IO가 된다.)
또 간혹 블로킹 IO는 '함수 호출 이 후 실제 IO 작업이 시작할 때까지 블로킹될 수 있는 IO' 라는 의미로 사용하고, 논블로킹 IO를 '함수 호출 이 후 실제 IO 작업이 시작할 때까지 블로킹되지 않는 IO' 라는 의미로 사용하기도 한다.
이 경우엔 동기 & 블로킹, 동기 & 논블로킹, 비동기 & 논블로킹 로 구분이 가능하며, 비동기 & 블로킹은 구조적으로 불가능하다. (굳이 그러한 라이브러리를 만들 수는 있으나 구조적으로 잘못된 설계다.)
결국 어느 레벨에서 이야기하는지에 따라 용어의 개념은 달라진다. 혼동을 피하기 위하여 언급을 피했지만, 커널 레벨에서는 결국 대부분의 IO가 비동기로 동작한다. 따라서 쓰레드 A와 쓰레드 B가 둘 다 write를 호출했을 때, 쓰레드 A가 먼저 호출했더라도 쓰레드 B의 write가 먼저 완료될 수도 있다.
아무튼 특정 언어나 라이브러리에서 용어를 따로 정의할 수는 있지만, 유저 어플리케이션이 IO 작업을 요청하기 위한 근간은 결국 시스템 콜이다. 시스템 콜 레벨에서는 언급한 바와 같이 블로킹/논블로킹은 동기 IO 내에서 구분하는 방식이고, 비동기 IO는 동기 IO와 아예 별도의 방식이다.
---
Reference
Non-blocking I/O; if no data is available to a read(2)
system call, or if a write(2) operation would block, the
read or write call returns -1 with the error EAGAIN.
The aio facility provides system calls for asynchronous I/O. Asynchro-
nous I/O operations are not completed synchronously by the calling
thread. Instead, the calling thread invokes one system call to request
an asynchronous I/O operation. The status of a completed request is
retrieved later via a separate system call.
Forms of I/O and examples of POSIX functions:
Blocking | Non-Blocking | Asynchronous | |
API | write, read | write, read + poll / select | aio_write, aio_read |
'IT' 카테고리의 다른 글
병렬성(Parallelism)과 동시성(Concurrency) (0) | 2024.07.21 |
---|---|
Linux Kernel Network Commit #3 (0) | 2021.08.25 |
Linux Kernel Network Commit #2 (1) | 2021.08.20 |
Linux Kernel 참고 사이트 추천 (4) | 2021.07.27 |
Linux Kernel Network Commit #1 (2) | 2021.07.24 |