IO와 메모리 복사
동기와 비동기 IO 에 대해 지난번에 이야기했었다. 비동기 IO 함수
간단히 요약하면, 함수는 완료 알림 시점 때문에 동기와 비동기로 구분된다는 것이다.
- 동기는 함수의 완료를 반환을 통해 알린다. 따라서 요청한 작업이 끝나기 전까지는 함수가 반환되지 않는다는 것. 덕분에 작업이 다른 작업에 의존적일 경우 흐름이 멈추는(Blocking) 경우가 많다.
- 비동기는 함수의 완료를 반환을 통해 알리지 않는다. Callback 을 불러주든 Event나 Signal을 날려주든, 완료 통지를 가져갈 때까지 모아두든 어쨌든 요청하는 함수는 바로 반환된다. (물론 요청 즉시 바로 완료되어 끝날 수 있지만) 그래서 흐름이 안 멈춘다(Non-Blocking)
이런 것에 익숙하지 않은 이유는 여지껏 작성한 함수들이 전부 로직 함수였기 때문이다. 단순히 더하고 빼고 하는데 함수 수행이 흐름을 멈춘다고 생각하지는 않으니까. 하지만 다른 Thread, Process의 수행에 의존적이거나 장치(Device) IO에 대기해야한다면 함수는 그 의존 작업이 해결될 때까지 대기(Blocking) 하는 수 밖에 없다. 그게 싫으면 작업 요청만 받아두고(Promise) 완료 통지는 나중에(Future) 해야지.
효율성에 대해 이야기하자면, 동기 방식은 수행 흐름을 멈추어야 하기 때문에 Scheduling 정책을 변경해야한다는 것이고, 그러면 커널 모드로 진입해야하니까 Mode Switch 에 현재 수행 흐름을 멈추고(Sleep, Suspend) 다른 수행 흐름을 재개(Wakeup, Resume)해야하므로 Context Switch 비용도 들어간다. 이게 CPU Cycle 을 엄청 잡아먹고 메모리 작업도 엄청 많고 그러다보면 IO 작업(Swap out 된 메모리 복구) 도 생길 수 있고 아무튼 기타 등등 무지하게 느려지게 된다.
고로 비동기를 쓰면 빠르다는 이야기가 나오는 것이고, 실제로 Java에서도 1.4에서 Non-Blocking IO 를 지원하는 NIO(New IO)를 내세워 우리도 빠르다! 를 외쳤다.
어쨌든 Non-Blocking은 Thread 사용량을 줄여주고, 흐름이 방해되지 않으며, Mode/Context Switch 이 덜 일어난다는 등 성능이 향상될 좋은 이유가 많다. 그러나 IO 효율을 이야기함에 있어서는 그것이 끝이 아니다.
메모리
IO 의 효율을 이야기할 때는 메모리 관점 역시 빼 놓을 수가 없다. IO 작업을 처리함에 있어서 꽤나 메모리 복사가 일어나기 때문인데, 그 이유는 가상 메모리(virtual memory)를 사용하기 때문이다.
가상 메모리가 무엇인가? 간단히 말하면, 운영체제가 각 프로세스에게 가상의 메모리 주소 체계를 제공하고, 그 가상 메모리 공간이 실제 물리 메모리 공간과 마법같이 연결(mapping)되어 프로세스가 메모리에 접근할 때 주소 변환 과정을 거쳐 실제 물리 메모리에 접근 가능하게 해주는 것이다.
이게 뭐가 좋은가?
- 메모리 주소 몰라도 코딩 가능하다. 16bit 시절 real address 시절을 살지 않았던 사람은 무슨 말인지 모르겠지. 물론 나도 모른다.
- 보안 정책이 강화된다. 다른 프로세스의 메모리 주소 공간과 내 주소 공간이 다른 세계이므로 그 쪽에 함부로 접근할 수 있는 방법이 없다. 접근하려면 운영체제에게 요청하고 허락받아야 한다.
- 물리 메모리는 작아도, 각 프로세스는 논리(가상) 메모리만 보기 때문에 넓다고 착각하고 살 수 있다. 실제 물리 메모리로의 연결은 운영체제가 해주니까. 메모리 공간 부족하면 알아서 하드 디스크로 내리겟지(swap out). 그러다가 다시 필요하면(page fault) 알아서 다시 읽어 줄 것이다.
메모리 영역은 커널 영역과 유저 영역으로 나누어진다. Windows 경우 32 bit일 때 대충 2GB, 2GB씩 갈라서 쓴다. 당연한 이야기이지만, 유저 영역과 커널 영역의 보안 정책은 다를 것이고, 무엇보다 중요한 건 유저 영역의 메모리는 실제 물리 메모리에 존재하지 않을 수 있다는 것이다. (swap out된 상태)
이 때문에 커널에 무언가 요청을 하는데 그것이 유저 메모리에 복사해주어야 경우, 즉 대부분의 IO 요청에 대해서는 문제가 발생할 수 있다.
잠깐 간단한 전제를 다함께 집고 넘어가자. 커널은 시스템을 보호해야할 목적이 있다. 하나의 악의적인 목적을 가진 프로세스로부터 다른 프로세스의 안정성을 지켜줘야할 필요가 있다. 유저는 위험하고, 멍청하고, 적이다!
다음의 예제를 보자.
char *buffer = new char[4096];
read_async (socket, buffer, 4096);
delete buffer; // 훼이크다, 커널아!
위의 read_async
요청은 적법하다. buffer 포인터도 유효하고, 공간도 잘 할당되어있다. 하지만 완료 통지는 언제 될지 모른다. 여기서 커널은 유저를 믿고 유저가 넘긴 포인터에다가 데이터를 덮어쓰면, 잘 돌아갈까?
잘못된 주소 공간에 덮어쓰거나, 운이 없으면 KERNEL PANIC (CRASH) 이 발생해서 시스템이 멈출 수 있다. 따라서 커널 모드에서 유저 영역의 메모리에 접근하는 것은 금기시? 되어버렸고, 적어도 죽어도 유저 모드에서 죽어라! 라는 심정으로 작업 결과를 커널 버퍼에 넣어주면, CRT (C Runtime) 함수들이 거기서 유저가 요청한 영역으로 다시 복사를 수행해주는 방법을 사용하는 것이다.
결국 커널에 유저 영역의 버퍼에 뭔가를 얻어오는 요청을 할 경우에는
- 유저 영역에 있는 API 함수 진입점에서 메모리 주소의 유효성 검사를 한다
- 유저 영역에 있는 API 함수에서 커널 영역에 있는 API 함수에게 요청을 한다.
- 커널 영역에 있는 API 함수가 결과를 커널 영역 내에 저장해둔다.
- 유저 영역에 있는 API 함수가 커널 영역에 있는 결과를 유저가 요청한 영역에 복사해준다.
따라서 API 함수는 유저 영역과 커널 영역 양 쪽으로 분할된다. 이렇게 되면 커널에서 잘못된 유저 메모리에 접근할 일이 없고, 접근한다고 해도 유저 영역의 API 함수 내에서 접근하기 때문에 그 프로세스만 종료되게 되는 것이다(segmentation fault)
같은 맥락으로 유저 영역 메모리에 있는 내용을 커널 모드에서 참조해야할 경우, 유저 영역 메모리가 없을 수도 있다.
char *buffer = new char[4096];
// 뭔가 내용을 buffer 에 쓴다
write_async (socket, buffer, written);
delete buffer; // 훼이크다, 커널아!
write_async
함수는 비동기 수행을 하므로, 이 함수의 수행이 delete보다 나중에 실행될 수 있다. 그 때 유저 영역의 메모리에 접근하려 한다면? 결과는 알 수 없다.
따라서 이러한 경우에도 write_async
함수에서 유저가 요청한 데이터를 안전한 커널 영역으로 복사해와야 하는 것이다.
(linux kernel의 copy_from_user
, copy_to_user
)
물론 이걸 제대로 설명하려면 paged pool 과 nonpaged pool 을 설명해야하지만 너무 내용이 길어지니 일단 이정도로 넘어가자.
다시 IO로
위의 장황한 설명을 IO 로 초점을 맞추어 다시 이야기해보자.
SEND 를 수행하려고 한다. 그럼 유저 영역에 있는 메모리의 내용을 커널 쪽에 요청해야할 것이다.
- 유저 영역의 메모리가 커널 영역에 복사가 된다.(Async 함수 요청할 때)
- 커널 영역의 데이터를 IO 드라이버를 통해 해당 장치로 내보낸다.(사용자의 IO 요청을 처리할 때)
RECV 를 수행하려고 한다. 그럼 커널 영역의 메모리를 거쳐서 유저 영역으로 복사해와야 한다.
- 유저 영역의 메모리 주소로 비동기적 Read 를 요청한다.
- 커널에서 IO 의 interrupt 를 받는다. interrupt handler 가 수행되고, 데이터를 보니까 아까 Read 요청한 거였네? 그러면 일단 IO 드라이버를 통해 수신 받은 데이터를 커널 영역에 복사해온다.(IO 장치의 IO 요청을 처리할 때)
- IO 완료 통지를 유저에게 해준다. 이 때 커널 영역에 있는 데이터가 유저가 지정했던 메모리로 다시 복사가 일어난다.
적어도 2번 일어난다. 유저와 커널. IO 드라이버 내에서 사용하는 것까지 하면 더 일어나겠지만 그건 어쩔 수 없는 영역이니 일단 넘어가자.
동기와 비동기에 대해서 묶어서 설명하고 있는데, 동기든 비동기든 메모리 복사는 어쨌거나 저 규칙을 따른다. 다만 수행 흐름이 멈추느냐, 완료 통지를 동기적으로 받을 수 있느냐의 차이일 뿐.
Java 의 IO 가 느린 이유가 여기있다. 예를 들어 Recv 작업을 요청한다고 했을 때, IO 버퍼, 커널 버퍼, JVM, Java Memory로의 복사 단계를 거쳐야하므로 속도가 훨씬 느릴 수 밖에 없다. 따라서 JVM 내의 native 한 메모리를 직접 IO에 사용할 수 있게 해준 ByteBuffer로 인해서 Java IO 속도가 개선되었고, 이것은 NIO의 핵심 개념 중 하나가 되었다.
속도 개선
유저가 코드를 잘 작성했다. 즉, 비동기 IO 요청이 끝나기 전까지 메모리 관리를 잘 해주었다. 그리고 커널이 튼튼해졌다. 유저의 악의적인 장난 쯤은 사소하게 여기면서 유저 프로세스를 종료시켜준다. 이러한 상황에서는, 커널 버퍼라는 중간 지점의 필요성이 모호해진다. 오히려 성능적 저해 요인이 될 뿐이다.
그렇다고 해도 저 문제를 한 번에 해결할 수는 없다. IO 요청과 메모리 Paging 수행의 IRQL 문제로 인해서 IO 요청이 일어날 때 해당 메모리가 Swap out 되어있으면 안되기 때문이다. (이건 Windows 에만 해당되는 것 같고, 사실 그마저도 아직 잘 모르겠어서 공부가 더 필요하다.)
- https://studyrespire.tistory.com/tag/IRQL
- https://bugtruck.blogspot.com/2009/03/non-paged-memory.html
커널이 유저 영역의 메모리에다가 결과를 직접 넣어주고 싶어도 해당 메모리가 swap out이 되어버린다면 그 처리가 애매해진다는 것. 그 이유는 디스크로 내려간 메모리를 물리 메모리로 올려주는 작업을 처리하는 것도 interrupt에 의한 것이고, IO 요청을 처리하는 것도 interrupt이기 때문이다. (물론 지연된 수행 방식에 따라 interrupt handler 에서는 최소한의 작업만 수행하고 실질적인 IO 드라이버가 동작하는건 kernel thread 에서 수행을 하겠지만)
논리 메모리 주소를 CPU가 fetch 하기 위해 여러 레지스터의 도움을 받아 실제 물리 메모리 주소를 접근했을 때, 그것이 디스크에 저장되어있으면 page fault exception (soft interrupt)이 발생한다. 그러면 그걸 처리하는 interrupt handler가 수행되면서 메모리를 적당한 곳에 복구해준다.
그런데 이게 IRQL 때문에 문제가 생기나? 아무튼 그래서 Windows에서는 유저가 IO 요청을 하면 그 버퍼에 직접 작업하기 위해서는 그 메모리 공간을 swap out이 불가능한 상태로 만들어준다. 이것을 locked page 라고 한다.
(사실 이쪽은 아직 잘 모르겠다)
간단히 말해 유저가 요청한 버퍼 공간을 lock 을 걸어서 paging 이 안되게 하겠다는 것. 그러면 IO 요청이 언제 수행되더라도 해당 메모리가 물리 메모리에 있음을 보장할 수 있으니까 문제가 없다는 것이다. (동기 IO 든 비동기 IO 든 IO 요청은 IO 드라이버 내의 로직에 따라 언제 수행될지 모른다. 그저 완료 통지가 함수 반환으로 일어나느냐 아니냐의 차이일 뿐)
즉, 어설프게 중간 다리인 커널 버퍼를 두지 않더라도 유저 영역에서 직접 IO 버퍼에 값을 쓰거나, IO 버퍼로부터 값을 읽어올 수 있게 된다는 것이다. 메모리를 복사하는데 들어가는 CPU 사용을 줄이니 당연히 효율이 좋아질 수 밖에 없다. 뿐만 아니라 multi core 인 현 세상을 고려해볼 때, cache나 memory access 등을 고려해보면 효율이 좋아지는 것은 어찌보면 당연할 수 밖에 없는 것 같다.
정리
약간의 커널적 개념과 하드웨어적 개념이 들어가서 글이 길어졌는데 요약해보면,
- IO 요청을 할 때는 적어도 유저 영역, 커널 영역, IO 드라이버 영역 간의 메모리 복사가 이루어진다.
- Windows 에서는 locked page를 사용하여 IO 요청을 할 때, 커널 모드에서 유저 영역과 IO 드라이버 영역의 메모리를 직접 교환 가능하게 하여 성능 효율을 향상시켰다.
하지만 locked page는 몇 가지 문제점을 야기하는데, 아무래도 물리적 메모리에서 디스크로 안 내려가는 메모리가 많아질 수록 가용 메모리 공간은 줄어들 것이고 시스템 성능은 저하될 것이다. 심해지면 커널 크래시가 날 것이다.
IRQL과 non paged pool에 대해서는 개념 부족으로 설명을 완전 대충했는데, 이에 대해서 좋은 내용이 있으면 거침없는 조언 부탁드린다.