Tokio는 비동기 IO를 수행하는 어플리케이션 개발을 위한 러스트 프레임워크다. 이는 이벤트 기반 접근으로 일반적인 동기 IO보다 더 좋은 확장성, 성능 그리고 자원 사용을 보여준다. 안타깝게도, tokio는 이의 난해한 추상화로 어렵기로 악명이 높다. 이 튜토리얼을 읽고 난 뒤에도 나는 실제로 어떻게 작동하는지 충분히 이해하지 못했다.
내 이전 비동기 IO 프로그래밍 경험이 Tokio를 공부하는데 방해가 되기도 했다. 나는 운영체제의 선택 기능 (리눅스의 epoll 같은) 을 시작 포인트로 잡고, 그다음 디스패치로, 상태머신, ..로 가는 것에 익숙했다. tokio의 추상화를 epoll_wait() 이 어디서 어떻게 작동하는지 이해없이 시작하는 것은, 졸라 어렵다. tokio와 future 기반 접근은 눈 가리고 걷는 것처럼 느껴진다.
tokio 를 공부하기 위해 탑다운으로 접근하는 거보다 바텀업으로 소스코드를 공부하는게 실제로 지금의 구현이 epoll 이벤트들이 Future::poll()의 IO 접근? 소비? 까지 가는지 이해하는게 더 낫다고 결정했다. tokio 와 futures 의 자세한 하이레벨 사용에 대한 자세한 내용은 들어가지 않을 것이다. 이미 충분히 좋은 튜토리얼이 있으니까.. 비동기 IO의 일반적인 문제에 관한 내용도 책 한권으로 쓰기에 충분하기에 짧은 요약만으로 설명하겠다. 내 목표는 futures와 tokio의 polling이 어떻게 작동하는지 자신감을 갖게 하기 위함이다.
들어가기전에, tokio는 아직 개발되고 있는 중이기에 여기 내용중 더이상 맞지 않은 내용이 있을 수도 있다. 이 공부를 위해서 나는 tokio-core 0.1.10과 futures 0.1.17, 그리고 mio 0.6.10을 사용했다. tokio-core 의 이벤트 시스템은 자주 바뀌니까 이야기를 안하겠다. 나는 tokio를 리눅스에서 공부했고 몇몇 이야기들은 epoll같은 플랫폼에 의존하는 세부적인 구현은 다를 수 있다. (의역) 마지막으로, 여기서 말하는 모든 것은 tokio 의 초보자로써 내 의견이므로 에러나 잘못 이해한 내용이 있을 수 있다.
동기 IO 프로그래밍은 실행을 완전히 블록하는 IO 조작을 제공한다. 데이터가 도착할때까지 읽기 함수는 진행을 블록할 것이고 쓰기 함수는 쓰여지는 모든 바이트들이 커널로 도착할 때 까지 진행이 블록될 것이다. 이는 한 명령이 끝나면 다음 명령어를 실행하는 전통적인 명령형 프로그래밍에 딱 들어맞는다. 예를 들어, 각 연결에 대해 새로운 스레드를 생성하는 HTTP 서버를 생각해보자. 이 스레드에서, 전체 요청이 도착할때까지 바이트들을 읽으며, 바이트들이 전부 도착할때까지 블락할테고, 그 후에는 요청을 처리할테고, 그리고는 응답을 작성하고, 역시 전부 쓰여질때까지 블록할 것이다. 이는 메우 직관적인 요청이다. 반면, 스레드 오버헤드는 매우 많은 연결을 처리하는 서버의 확장성에 문제가 되고 자원이 부족한 시스템이 몇몇 연결을 처리하는데 무리가 갈 수도 있다.
만약 우리의 HTTP서버가 비동기 IO를 사용하여 작성되었다면 이는 싱글스레드에서 모든 IO처리를 할 수 있다. 모든 활성화된 연결과 대기중인 소켓은 논블로킹으로 구성되었고, 이벤트 루프에서 읽기/쓰기 상태를 모니터링하며, 실행은 핸들러로 디스패치될 수 있다. 스테이트와 버퍼는 각 연결을 유지하는데 필요할 수 있다. 만약 핸들러가 200바이트 요청중 100바이트만 읽을 수 있다면 이는 남은 바이트가 도착하기를 마냥 기다릴 수는 없다. 다른 연결의 작업을 막을 수 있다. 대신 이는 무조건 읽은 것의 일부를 버퍼에 저장하고 현재 스테이트를 "요청 읽는 중" 으로 설정하여 다시 이벤트 루프로 돌아가야 한다. 다음에 핸들러가 이 연결로 불려지면 요청의 나머지를 읽고 "응답 작성 중" 으로 스테이트를 변경하면 된다. 이런 시스템을 구현하는 것은 복잡한 상태 머신과 오류가 발생하기 쉬운 자원 처리로 코드가 금방 지저분해질 것이다.
이상적인 비동기 IO 프레임워크는 이러한 IO 처리 단계를 차레대로 작성하는 수단을 제공한다. 블록하는 것처럼 보여도 뒤에서는 이벤트 루프와 상태 머신을 생성하고 있다. 많은 언어에서 이는 어려운 목표지만, tokio는 어느정도 비슷하게 해냈다.
tokio 스택은 다음의 컴포넌트들을 포함하고 있다:
- 시스템 셀렉터. 각각의 운영체제는 IO 이벤트를 받을 방법을 제공한다. 리눅스의 epoll이나 FreeBSD/Mac OS의
kqueue()나 윈도우의 IOCP와 같은 것들 말이다. - Mio - Metal IO. Mio는 로우레벨 IO에 대한 일반적인 API를 각 운영체제의 자세한 내용을 내부적으로 핸들링하여 제공하는 러스트 크레이트다. Mio는 너가 각 운영체제의 시스템 셀렉터의 스펙을 하나하나 몰라도 되게끔 해준다.
- Futures. futures 일어날 일들을 나타내는것에 대한 강력한 추상화를 제공한다. 이렇게 나타내지는 것은 복잡한 이벤트의 시퀀스를 나타내는 쓰까된 futures을 만들기 위해 쉽게 합쳐질 수 있다. 이러한 추상화는 IO로 다른 많은 것도 할 수 있지만, futures는 tokio에서 우리가 개발할 비동기 IO 상태 머신에 사용 될 것이다.
- Tokio.
tokio-core크레이트는 Mio와 합쳐지는 주요한 이벤트 루를 제공하여 IO 이벤트에 응답하고 futures로 끝나게끔 도와준다. - 너의 프로그램. tokio 프레임워크를 사용하는 프로그램은 futures로 비동기 IO를 구축할 수 있고 이걸 tokio 이벤트 루프에 실행을 위해 제공할 수 있다.
Mio는 호출자가 소켓 읽기/쓰기 상태 변화와 같은 이벤트를 받을 수 있는 로우레벨 IO API를 제공한다. 중요한 것들은:
- Poll과 Evented. Mio는 Evented 트레잇을 제공하여 어떤것이든 이벤트에 쓰일 수 있게끔 나타내어 준다. 너의 이벤트 루프에서, 너는
Evented의 개수를mio::Poll오브젝트를 사용하여 등록하고mio::Poll::poll()를 호출하여 하나 이상의 Evented 오브젝트들에 이벤트가 발생할때까지 블록할 수 있다. - 시스템 셀렉터. Mio는 시스템 셀렉터를 크로스 플랫폼으로 제공하여 리눅스의 epoll이나 윈도우의 IOCP나 FreeBSD/맥의
kqueue()혹은 다른 것들을 같은 API로 쓸 수 있게끔 해준다. Mio API에 시스템 셀렉터를 적용하는 데에 드는 오버헤드는 다르다. 왜냐하면 Mio는 리눅스의 epoll과 비슷한 readiness-based API를 제공하기에 Mio를 리눅스에서 사용하는 것은 API의 많은 부분들이 하나에 하나를 매핑하는게 가능하게끔 한다. (예를 들어,mio::Events는 기본적으로struct epoll_event의 배열이다.) 반대로, 윈도우의 IOCP는 readiness 기반이 아닌 completion 기반이기에 두 패러다임 사이에서 더 많은 적용이 필요하다. Mio는TcpListener,TcpStream,UdpSocket과 같은 자신의 각 버전의std::net구조체들을 제공한다.이것들은std::net버전들을 가리지만 논 블록킹이 기본값이며 시스템 셀렉터에 소켓을 추가한Evented구현을 제공한다. - 논시스템 이벤트 (번역: 시스템 이벤트가 아닌 이벤트 정도로 생각?) 이외에 IO 소스들의 readiness를 준비하는데, Mio는 또한 유저 스페이스에서 생성된 readiness 이벤트도 일어나게 한다. 예를 들어, 만약 돌아가는 스레드가 작업 뭉치를 끝내면, 이는 이벤트 루프 스레드에 끝났다는 신호를 줄 수 있다. 너의 프로그램은
Registration::new2()를 호출하여(Registration, SetReadiness)쌍을 얻는다.Registration오브젝트는 이벤트 루프에 Mio를 통해 등록되는Evented이고,set_readiness()는SetReadiness오브젝트에서 readiness가 신호되어야 할 때 언제나 불려질 수 있다. 리눅스에서, 논시스템 이벤트 알림은 파이프를 이용해 구현되어 있다.SetReadiness::set_readiness()가 호출될때,0x01바이트가 파이프에 쓰여진다.mio::Poll의 아래있는 epoll은 파이프의 끝까지 읽는 것을 모니터링 하기로 구성되어서,epoll_wait()는 언블럭하고 미오는 호출자에게 이벤트를 전달해준다. 얼마나 많은 논시스템 이벤트들이 나중에 등록되어도 정확히 하나의 파이프만이Poll이 생성되었을때 만들어진다.
모든 Evented 등록은 호출자로부터 주어진 mio::Toke으로 타입된 usize 값과 연관이 되어 있다. 이 값은 해당 등록을 나타내는 이벤트와 함께 반환된다. 토큰이 64비트에서 함수가 epoll_data 공용체에 위치할 수 있어서 이는 리눅스에서 시스템 셀렉터와 부드럽게 매핑될 수 있다.
Mio 작동의 구체적인 예를 제공하기 위해, 우리가 Mio를 리눅스 시스템에서 UDP 소켓을 모니터링할 때 내부적으로 무엇이 일어나는지 보여주겠다.
- 소켓을 만듦.
let socket = mio::net::UdpSocket::bind(
&SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::new(127,0,0,1)),
2000
)
).unwrap();이 코드는 std::net::UdpSocket으로 싸여 있는 리눅스 UDP 소켓을 만든다. 이는 또 mio::net::UdpSocket으로 싸여 있다. 이 소켓은 논블록킹으로 되어 있다.
- poll을 만든다.
let poll = mio::Poll::new().unwrap();Mio는 시스템 셀렉터, 논시스템 이벤트를 위한 readiness 큐, 그리고 병행성 보호를 초기화한다. 이 readiness 큐 초기화는 파이프를 만들어 readiness가 유저스페이스에서 신호되어 파이프의 파일 읽는 디스크립터가 epoll에 추가될 수 있게 한다. Poll 오브젝트가 생성될때, 이 오브젝트에는 특별한 selector_id 값이 증가하는 카운터로부터 할당된다.
- 소켓을 poll을 사용하여 등록.
poll.register(
&socket,
mio::Token(0),
mio::Ready::readable(),
mioLLPollOpt::level()
).unwrap();소켓의 파일 디스크립트를 폴 셀렉터에 추가하는 포함된 EventedFd로 프록시해주는 (전적으로 다음 함수를 사용함: epoll_event.data가 주어진 토큰 값으로 설정되어진 epoll_ctl(fepd, EPOLL_CTL_ADD, fd, &epoll_event)) UdpSocket의 Evented.register() 함수가 불려진다. UdpSocket이 등록되었을 때, 이의 selector_id는 Poll의 값으로 설정되어 셀렉터와 연관되게 한다.
- 이벤트 루프에서 poll()를 호출함.
loop {
poll.poll(&mut events, None).unwrap();
for evnet in &events {
handle_event(event);
}
}시스템 셀렉터 epoll_wait()와 그리고 readiness 큐가 새로운 이벤트를 위해 poll 된다. (epoll_wait()는 블록킹하는 함수이다, 하지만 이는 논시스템 이벤트들이 epoll을 readiness 큐에 넣기 위해 파이프로 트리거하여 와 졸라 뭐라는 거지 하나도 모르겠다) 합쳐진 이벤트의 집합은 호출자가 처리를 가능하게 만든다.
futures는 일어날 일들이 "future" 로 표현되는 함수형 프로그래밍의 처리에서 가져온 기술이다. 이러한 개인의 futures는 합쳐저 복잡한 시스템을 개발할 수 있다. 이는 트랜잭션을 하기 위한 기본 스텝들이 합쳐진 futures로 구성될 수 있어 유용하다.
살려주세요 이게 뭐야