비동기 입출력
1. 개요
비동기 입출력(I/O)은 I/O 요청 후 완료 여부를 즉시 확인하지 않고 콜백이나 신호 등의 별도 메커니즘을 통해 확인하는 방식을 의미한다. 블로킹/논블로킹, 동기/비동기 개념과 구분되며, 일반적으로 논블로킹 방식으로 동작하지만 항상 그런 것은 아니다. 비동기 I/O는 버퍼 처리, 입출력 성패 확인, 시스템 콜 종료 등의 특징을 가지며, 실시간 운영체제, 엔터프라이즈 환경, 이벤트 드리븐 프레임워크 등 다양한 환경에서 사용된다. 구현 방식은 프로세스 분산, 폴링, select 루프, 콜백, 스레드, 파이버, 완료 큐 등 운영체제, 프로그래밍 언어, 라이브러리에 따라 다양하다. 자바, C++, C#, 파이썬, 자바스크립트 등 여러 프로그래밍 환경에서 비동기 프로그래밍을 지원하며, 리눅스와 윈도우 등 운영체제에서도 구현된다.
-
이벤트 (컴퓨팅) -
메시지 큐
메시지 큐는 프로세스나 스레드 간 비동기 통신을 제공하여 송신자와 수신자가 동시에 상호 작용할 필요 없이 메시지를 교환하도록 하며, 메시지는 수신자가 검색할 때까지 저장되고 시스템 장애 시 복원력을 제공하지만 보안 취약점, 특정 기업 종속성 등의 논란도 존재한다. -
이벤트 (컴퓨팅) -
폴링 (컴퓨터 과학)
폴링은 컴퓨터 과학에서 컴퓨터나 제어 장치가 외부 장치의 상태를 주기적으로 검사하는 방식으로, 인터럽트 방식에 비해 CPU 사용량이 높을 수 있지만 단순한 시스템에서 효과적이다. -
동시성 제어 -
세마포어
세마포어는 데이크스트라가 고안한 정수 변수로, P/V 연산을 통해 자원 접근을 제어하고 동기화 문제를 해결하며, 계수 세마포어와 이진 세마포어로 나뉘어 멀티스레드 환경에서 자원 관리 및 스레드 동기화에 기여한다. -
동시성 제어 -
모니터 (동기화)
모니터는 공유 자원 접근을 제어하여 프로세스 간 동기화를 구현하는 프로그래밍 구조로, 뮤텍스 락, 조건 변수 등으로 구성되어 경쟁 상태를 방지하며 여러 프로그래밍 언어에서 지원된다. -
입출력 -
터치 타이핑
터치 타이핑은 키보드를 보지 않고 손가락 위치를 기억해 타이핑하는 기술로, 효율적인 방법으로 알려져 있으며 꾸준한 연습으로 속도와 정확성을 향상시킬 수 있다. -
입출력 -
입력
입력은 컴퓨터에 정보나 명령을 전달하는 방식으로, 키보드, 마우스, 스캐너, 음성 입력 장치, 게임 컨트롤러 등 다양한 장치를 통해 이루어지며 '가져오다' 또는 '읽어 들이다'와 같은 의미로도 사용된다.
2. 블로킹/논블로킹, 동기/비동기
비동기 입출력(I/O)은 논블로킹 I/O와 매우 자주 혼동되지만, 동기/비동기, 블로킹/논블로킹이라는 분류는 반드시 일치하지 않는다. POSIX 환경에서 `O_NONBLOCK`이 설정된 파일 디스크립터에 대해 일반적인 `read`나 `write`를 수행하면 논블로킹이 되지만, "블록될 것 같으면 에러로 한다"는 동작이므로 비동기가 되는 것은 아니다. (대부분의 I/O 작업은 운영체제(OS) 내의 버퍼 등에 의해, 동기형 API에서도 블록되지 않고 완료될 수 있는 경우도 많다).
비동기 I/O는 다음과 같은 특징을 갖는다.
# 버퍼의 내용이 커널 등에 의해 복사되거나, 또는 프로그래머의 책임으로 처리가 완료될 때까지 요구하는 프로세스가 그것을 유지해야 한다.
# (권한 위반 등, 즉시 커널이 에러 등으로 할 수 있는 경우를 제외하고) 입출력의 성패도, 입출력을 요구하는 시스템 콜의 결과로는 얻을 수 없으며, 콜백 또는 다른 시스템 콜 등으로 다시 얻을 필요가 있다.
# 위와 같은 제한 하에, 입출력 요구의 시스템 콜은 블록되지 않고, 최소한의 처리로 즉시 종료된다.
이러한 API 스타일에 의한 I/O가 비동기 I/O이다. 따라서 비동기 I/O가 이용되는 것은, "시간 제약이 엄격한 RTOS이기 때문에"와 같은 이유가 아니다. 상호 배제 제어의 사정 등으로 블록시킬 수 없거나, 또는 성능상의 이유로 엔터프라이즈 용도로 요구되는 경우도 있으며, 이벤트 드리븐 형 프레임워크이기 때문에 필요하다는 경우도 있다.
비동기 입출력 형태와 POSIX 함수의 예시는 다음과 같다.
| 블로킹 | 논블로킹 | 비동기 | |
|---|---|---|---|
| API | write, read | write, read + poll / select | aio_write, aio_read |
모든 형태의 비동기 입출력은 잠재적인 자원 충돌과 관련된 실패의 가능성을 열어둔다. 이를 방지하기 위해서는 상호 배제, 세마포어 등을 사용하는 주의 깊은 프로그래밍이 필요하다.
3. 구현 방식
다양한 운영체제, 프로그래밍 언어, 라이브러리에서 비동기 I/O를 구현하는 여러 방식이 존재한다. 주요 구현 방식은 다음과 같다:
* 프로세스 분산: 초기 유닉스 시스템에서 사용된 방식으로, 여러 프로세스가 독립적으로 실행되며 각각 자체 I/O 흐름을 처리한다. 파이프라인을 통해 연결된다. 프로세스 생성 및 유지 비용이 크다는 단점이 있다. 데이터 흐름 프로그래밍으로 확장될 수 있다.
* 폴링(Polling):
* 논블로킹 동기 API를 제공하며, 프로세스가 반복적으로 폴링하여 CPU 시간을 낭비할 수 있다.
* 하드웨어 I/O 병렬 처리를 완전히 활용하지 못할 수 있다.
* 예시:
* select: BSD 유닉스 기반 시스템에서 사용되며, `select` 시스템 호출을 사용하여 파일 디스크립터의 조건 발생, 타임아웃, 시그널 수신을 대기한다. 이벤트 루프로 구현될 수 있다.
* poll: SVR3 유닉스에서 제공되며, `select`와 유사하다.
* [[리눅스]]의 `[[epoll]]`: 이벤트 발생 소스만 반환.
* [[FreeBSD]]의 `[[kqueue]]`.
* [[Solaris (operating system)|Solaris]]의 [[이벤트 포트]].
* 신호(Signal): BSD 및 POSIX 유닉스에서 사용되며, I/O 완료 시 신호(인터럽트)가 생성된다. 신호 처리기 내에서 사용 가능한 기능이 제한적이며, 데이터 구조 불일치 문제가 발생할 수 있다.
* 콜백(Callback): 클래식 Mac OS, VMS, Windows에서 사용된다. 각 I/O 요청에 자체 완료 함수가 할당된다. 스택 깊이 문제가 발생할 수 있지만, 일반적으로 새로운 I/O 시작 시 자체 반환되어 해결된다.
* [[경량 프로세스]](LWP) 또는 [[스레드 (컴퓨터 과학)|스레드]]: 각 LWP 또는 스레드가 블로킹 동기 I/O를 사용한다. 많은 스레드가 필요하여 웹 서버와 같은 대규모 애플리케이션에는 부적합하다. Erlang 런타임 시스템에서도 사용된다.
* [[파이버 (컴퓨터 과학)|파이버]] / [[코루틴]]: Erlang 런타임 시스템 외부에서 비동기 I/O를 수행하는 경량 접근 방식이다.
* 완료 큐(Completion Queue): 마이크로소프트 윈도우, 솔라리스, AmigaOS, DNIX, 리눅스(io_uring 사용)에서 사용된다. I/O 요청은 비동기적으로 발행되고, 완료 알림은 동기화 큐를 통해 제공된다.
* 완료 이벤트 플래그(Completion Event Flag): VMS 및 AmigaOS에서 사용되며, 깊이가 1인 완료 큐와 유사하다.
* 채널 I/O: IBM, 불 그룹(Groupe Bull), 유니시스(Unisys) 메인프레임에서 사용된다. 보조 프로세서가 I/O 작업을 처리하여 CPU 사용률과 처리량을 최대화한다.
* IOPS 최적화: Windows Server 2012 및 Windows 8에서 사용되며, 대량의 메시지를 처리하는 애플리케이션에 최적화되어 있다.
일반적인 컴퓨팅 하드웨어는 폴링과 인터럽트, 두 가지 방법으로 비동기 I/O를 구현한다. DMA는 폴링 또는 인터럽트당 더 많은 작업을 수행하는 수단이다.
리눅스에서는 POSIX-XSI, POSIX 1003.1b, io_uring 구현이 이루어지고 있다.
윈도우에서는 Windows NT 3.1 이후 모든 버전에서 구현이 이루어지고 있다. 윈도우 API는 비동기 버전 함수를 제공하며, "I/O 완료 포트"를 통해 최적의 워커 스레드 수 제어와 I/O 오프로드를 구현할 수 있다.
4. 비동기 프로그래밍 환경
Java 1.5에서 표준화된 나 C++11에서 표준화된 `std::async`/`std::future` 등은 스레드 기반의 Future를 실현한다. .NET Framework/.NET Core에서는 C# 등 .NET 언어에서 사용할 수 있는 Task Parallel Library영어 (TPL)와 Microsoft Visual C++의 동시 실행 런타임에서는 C++에서 사용할 수 있는 Parallel Patterns Library영어 (PPL)이 제공된다.
이를 발전시켜 C# 5.0/VB.NET 11 이후, Python 3.5 이후에는 async/await 구문이 제공된다. F#에는 비동기 워크플로(asynchronous workflow)라고 불리는, TPL과는 다른 독자적인 인프라를 이용한 비동기 프로그래밍 기능이 있다. C++에서는 C++20에서 `co_await` 구문이 표준화되었다.
JavaScript는 싱글 스레드로 동작하므로 비동기 프로그래밍은 이벤트 구동 기반의 유사한 방식에 의존했지만, Web Worker에 의해 멀티 스레드 프로그래밍이 지원된다. (Web Worker의 스레드는 메모리 공간을 공유하지 않고, 실제로는 메시지 기반의 멀티 프로세스 프로그래밍이다.) ECMAScript 2015 (ES2015)에서는 Promise가, ES2017에서는 async/await 구문이 표준화되었다.
5. 예제
I/O를 읽는 세 가지 접근 방식의 예시이다. 객체와 함수는 추상적이다.
* 블로킹 및 논블로킹, 동기: `IO.poll()`은 최대 5초 동안 블로킹될 수 있지만 `device.read()`는 그렇지 않다.
```python
device = IO.open()
ready = False
while not ready:
print("읽을 데이터가 없습니다!")
ready = IO.poll(device, IO.INPUT, 5) # 5초가 경과하거나 읽을 데이터가 있는 경우(INPUT) 제어를 반환한다.
data = device.read()
print(data)
```
* 논블로킹, 비동기
```python
ios = IO.IOService()
device = IO.open(ios)
def inputHandler(data, err):
"입력 데이터 처리기"
if not err:
print(data)
device.readSome(inputHandler)
ios.loop() # 모든 작업이 완료될 때까지 대기하고 모든 적절한 처리기를 호출합니다.
```
* Async/await
```python
ios = IO.IOService()
device = IO.open(ios)
async def task():
try:
data = await device.readSome()
print(data)
except Exception:
pass
ios.addTask(task)
ios.loop() # 모든 작업이 완료될 때까지 대기하고 모든 적절한 처리기를 호출합니다.
```
* Reactor 패턴
```python
device = IO.open()
reactor = IO.Reactor()
def inputHandler(data):
"입력 데이터 처리기"
print(data)
reactor.stop()
reactor.addHandler(inputHandler, device, IO.INPUT)
reactor.run() # 이벤트를 처리하고 적절한 처리기를 호출하는 리액터를 실행합니다.
5.1. 블로킹, 동기
python
device = IO.open()
data = device.read() # 장치에 데이터가 있을 때까지 스레드가 차단됩니다.
print(data)
```
이 방식은 I/O 장치에 데이터가 도착할 때까지 스레드(프로그램의 실행 흐름)가 차단(block)되어 다른 작업을 수행하지 못하고 대기하는 방식이다. 데이터가 도착하면 `device.read()` 함수가 데이터를 반환하고, `print(data)` 문이 실행되어 데이터를 출력한다. 이 방식은 코드가 간결하고 이해하기 쉽지만, I/O 작업이 완료될 때까지 다른 작업을 수행할 수 없다는 단점이 있다.
5.2. 블로킹 및 논블로킹, 동기
* 블로킹, 동기: I/O 장치에 데이터가 있을 때까지 스레드가 차단되는 방식이다.
* 예시:
```python
device = IO.open()
data = device.read() # 장치에 데이터가 있을 때까지 스레드가 차단된다.
print(data)
```
* 블로킹 및 논블로킹, 동기: `IO.poll()`은 최대 5초 동안 블로킹될 수 있지만, `device.read()`는 그렇지 않다.
* 예시:
```python
device = IO.open()
ready = False
while not ready:
print("읽을 데이터가 없습니다!")
ready = IO.poll(device, IO.INPUT, 5) # 5초가 경과하거나 읽을 데이터가 있는 경우(INPUT) 제어를 반환한다.
data = device.read()
print(data)
```
* 논블로킹, 비동기: Async/await를 사용하거나, Reactor 패턴을 사용하는 예시이다.
* 예시 (Async/await):
```python
ios = IO.IOService()
device = IO.open(ios)
async def task():
try:
data = await device.readSome()
print(data)
except Exception:
pass
ios.addTask(task)
ios.loop() # 모든 작업이 완료될 때까지 대기하고 모든 적절한 처리기를 호출한다.
```
* 예시 (Reactor 패턴):
```python
device = IO.open()
reactor = IO.Reactor()
def inputHandler(data):
"입력 데이터 처리기"
print(data)
reactor.stop()
reactor.addHandler(inputHandler, device, IO.INPUT)
reactor.run() # 이벤트를 처리하고 적절한 처리기를 호출하는 리액터를 실행한다.
```
비동기 I/O는 논블로킹 I/O와 매우 자주 혼동되지만, 동기 또는 비동기, 블로킹 또는 논블로킹이라는 분류는 반드시 일치하지 않는다. POSIX 환경에서 `O_NONBLOCK`이 설정된 파일 디스크립터에 대해 일반적인 read나 write를 수행하면 논블로킹이 되지만, "블록될 것 같으면 에러로 한다"는 동작이므로, 비동기가 되는 것은 아니다 (대부분의 I/O 작업은 OS 내의 버퍼 등에 의해, 동기형 API에서도 블록되지 않고 완료될 수 있는 경우도 많다).
비동기 I/O는 다음과 같은 스타일의 입출력 API에 의한 I/O이다.
# 버퍼의 내용이 커널 등에 의해 복사되거나, 또는 프로그래머의 책임으로 처리가 완료될 때까지 요구하는 프로세스가 그것을 유지해야 한다.
# (권한 위반 등, 즉시 커널이 에러 등으로 할 수 있는 경우를 제외하고) 입출력의 성패도, 입출력을 요구하는 시스템 콜의 결과로는 얻을 수 없으며, 콜백 또는 다른 시스템 콜 등으로 다시 얻을 필요가 있다.
# 이상과 같은 제한 하에, 입출력 요구의 시스템 콜은 블록되지 않고, 최소한의 처리로 즉시 종료된다.
이러한 비동기 I/O가 이용되는 경우는 "시간 제약이 엄격한 RTOS이기 때문에"와 같은 이유뿐만 아니라, 상호 배제 제어의 사정 등으로 블록시킬 수 없거나, 성능상의 이유로 엔터프라이즈 용도로 요구되는 경우, 또는 이벤트 드리븐 형 프레임워크이기 때문에 필요한 경우 등 다양하다. 별도의 스레드를 사용하여, 프로세스 내에서 비동기 I/O처럼 보이게 하는 라이브러리(프레임워크) 등도 있을 수 있다.
5.3. 논블로킹, 비동기
논블로킹, 비동기 방식은 입출력(I/O) 작업이 완료될 때까지 기다리지 않고 즉시 제어를 반환하는 방식이다. 이 방식은 다음과 같은 특징을 가진다.
* 논블로킹 (Non-blocking): 입출력 호출은 즉시 반환되며, 작업 완료 여부와 관계없이 다음 코드를 실행할 수 있다.
* 비동기 (Asynchronous): 입출력 작업은 백그라운드에서 실행되며, 완료되면 콜백 함수나 다른 메커니즘을 통해 결과를 통지받는다.
다음은 논블로킹, 비동기 I/O를 구현하는 예시이다.
```python
ios = IO.IOService()
device = IO.open(ios)
def inputHandler(data, err):
"입력 데이터 처리기"
if not err:
print(data)
device.readSome(inputHandler)
ios.loop() # 모든 작업이 완료될 때까지 대기하고 모든 적절한 처리기를 호출합니다.
```
위 코드에서 `device.readSome()` 함수는 논블로킹 방식으로 데이터를 읽는다. 데이터가 준비되지 않아도 즉시 반환되며, 데이터가 준비되면 `inputHandler`라는 콜백 함수가 호출되어 결과를 처리한다. `ios.loop()`는 모든 비동기 작업이 완료될 때까지 대기하는 역할을 한다.
Async/await를 사용하면 동일한 예시를 다음과 같이 작성할 수 있다.
```python
ios = IO.IOService()
device = IO.open(ios)
async def task():
try:
data = await device.readSome()
print(data)
except Exception:
pass
ios.addTask(task)
ios.loop() # 모든 작업이 완료될 때까지 대기하고 모든 적절한 처리기를 호출합니다.
```
Reactor 패턴을 사용한 예시는 다음과 같다.
```python
device = IO.open()
reactor = IO.Reactor()
def inputHandler(data):
"입력 데이터 처리기"
print(data)
reactor.stop()
reactor.addHandler(inputHandler, device, IO.INPUT)
reactor.run() # 이벤트를 처리하고 적절한 처리기를 호출하는 리액터를 실행합니다.
```
비동기 I/O는 논블로킹 I/O와 혼동되기도 하지만, 엄밀히 말하면 동기/비동기와 블로킹/논블로킹은 반드시 일치하는 개념은 아니다. POSIX 환경에서 `O_NONBLOCK` 플래그를 설정하여 파일을 읽거나 쓰는 것은 논블로킹이지만, "블록될 것 같으면 에러로 처리"하는 동작이므로 비동기는 아니다.
비동기 I/O는 다음과 같은 특징을 갖는 입출력 API를 사용한다.
1. 프로세스는 버퍼의 내용이 커널 등에 의해 복사되거나, 프로그래머의 책임으로 처리가 완료될 때까지 버퍼를 유지해야 한다.
2. 입출력의 성공 여부는 입출력을 요구하는 시스템 콜의 결과로 즉시 얻을 수 없으며, 콜백 또는 다른 시스템 콜을 통해 확인해야 한다.
3. 입출력 요구 시스템 콜은 블록되지 않고 최소한의 처리만으로 즉시 종료된다.
비동기 I/O는 "시간 제약이 엄격한 RTOS이기 때문에" 사용되는 것은 아니다. 상호 배제 제어 문제로 블록될 수 없거나, 성능상의 이유로 엔터프라이즈 환경에서 요구되거나, 이벤트 드리븐형 프레임워크에서 필요하기 때문에 사용될 수 있다. 별도의 스레드를 사용하여 프로세스 내에서 비동기 I/O처럼 동작하는 라이브러리도 존재한다.
5.4. Async/Await (Python)
다음은 Async/await를 사용한 파이썬 예시이다.
```python
ios = IO.IOService()
device = IO.open(ios)
async def task():
try:
data = await device.readSome()
print(data)
except Exception:
pass
ios.addTask(task)
ios.loop() # 모든 작업이 완료될 때까지 대기하고 모든 적절한 처리기를 호출합니다.
```
위 코드는 I/O를 읽는 세 가지 접근 방식 중 논블로킹, 비동기 방식을 보여준다. 객체와 함수는 추상적이다.
다음은 Reactor 패턴을 사용한 예시이다.
```python
device = IO.open()
reactor = IO.Reactor()
def inputHandler(data):
"입력 데이터 처리기"
print(data)
reactor.stop()
reactor.addHandler(inputHandler, device, IO.INPUT)
reactor.run() # 이벤트를 처리하고 적절한 처리기를 호출하는 리액터를 실행합니다.
```
6. 리눅스에서의 샘플 프로그램
c
/* 비동기 I/O를 사용한 파일 출력 예시 */
#include
#include
#include
#include
#include
#include
#include
#define DATA_BUF_SIZE 4096
#define DATA_BUF_NUM 128
int main(void)
{
int fd;
int n, status;
unsigned char *Aio_buff[DATA_BUF_NUM];
struct aiocb Aiocb[DATA_BUF_NUM];
struct aiocb *List[DATA_BUF_NUM];
if ((fd = open("datafile", (O_CREAT | O_WRONLY), 0666)) < 0)
{
exit(1);
}
/* 링크 리스트 생성 */
for (n = 0; n < DATA_BUF_NUM; n++)
{
Aio_buff[n] = (unsigned char*)memalign(sysconf(_SC_PAGESIZE), DATA_BUF_SIZE);
memset((void *)(Aio_buff[n]), n, DATA_BUF_SIZE); /* 데이터는 페이지 단위로 n으로 채움 */
Aiocb[n].aio_buf = Aio_buff[n];
Aiocb[n].aio_offset = (long long)(DATA_BUF_SIZE * n);
Aiocb[n].aio_nbytes = (long long)DATA_BUF_SIZE;
Aiocb[n].aio_fildes = fd;
Aiocb[n].aio_reqprio = 0;
Aiocb[n].aio_lio_opcode = LIO_WRITE;
Aiocb[n].aio_sigevent.sigev_notify = SIGEV_NONE;
List[n] = &Aiocb[n];
}
/* 비동기 I/O 발행 */
lio_listio(LIO_WAIT, (struct aiocb )&List[0], DATA_BUF_NUM, &sig);
//
/* 이 사이에 파일 출력과 병행하여 다른 처리를 기술할 수 있다. */
//
/* 비동기 I/O 종료 대기 */
aio_suspend((const struct aiocb )&List[0], DATA_BUF_NUM, &timeout);
/* 에러 상태 확인 */
for (n = 0; n < DATA_BUF_NUM; n++)
{
status = aio_error(&(Aiocb[n]));
if (status) printf("%d is error %d:%s\n", n, status, strerror(status));
}
/* 종료 상태 확인 */
for (n = 0; n < DATA_BUF_NUM; n++)
{
status = aio_return(&(Aiocb[n]));
if (status != DATA_BUF_SIZE) printf("%d is write error\n", n);
}
close(fd);
return 0;
}