예전에 회사 프로젝트를 위해서 간단한 FCFS (first come, first service) 방식의 스케줄러를 구현한 적이 있었다. 물론 스케줄러라고 하기도 뭣할 정도의 간단한 코드였는데 해당 코드를 추가하고 나서는 독립적인 작업들을 제어하기가 훨씬 쉬워졌고 코드도 깔끔해졌다. 그런데 FCFS 방식에서 알 수 있듯이 실행 시간은 짧고 스케줄 주기가 긴 작업들이 주를 이룰 때는 아주 적절했으나, 실행 시간이 길고 주기가 짧은 작업이 추가되면서 주요한 작업들이 지연되는 현상이 발생하는 것을 알 수 있었다. 즉, I/O 완료 대기와 같은 작업 때문에 실제로 제 시간에 수행해야 하는 중요한 작업들이 실행이 안되는 현상이 발생한 것이다.
오늘 시간이 나서 해당 코드를 라운드 로빈 방식으로 변경하는 시도를 해 보았다. 뭐 라운드 로빈이라고 어려운 작업은 아니다. 기본 전략은 이랬다. 스레드 두 개를 만든다. 하나는 실제로 스케줄링 작업이 돌아갈 스레드고(이하 워커 스레드), 다른 한 스레드는 일정 시간 주기로 작업이 돌아가는 스레드를 선점해서 스케줄링을 해주는 스레드였다. 즉, 두 번째 스레드가 퀀텀 타임아웃 인터럽트 핸들러 정도가 되는 셈이다. 작업을 교체하는 작업도 어렵지는 않다. 작업별로 스택과 컨텍스트를 저장할 공간을 만들고 작업 저장은 GetThreadContext로, 작업 교체는 SetThreadContext로 하면된다.
그런데 웃기게도 이 간단해 보이는 작업은 시작부터 순조롭지 않았다. 워커 스레드를 만들었다. 해당 스레드는 당연히 WaitForSingleObject나 Sleep이라는 간단한 함수를 무한 반복하는 구조를 가지고 있다. 이제 스케줄러를 만들고 해당 스레드의 컨텍스트를 새로운 함수 위치로 변경했다. 그리고는 ResumeThread를 호출한다. 당연히 여기서 우아하게 작업 함수가 수행되면서 디버그뷰에 Job1이란 문장이 출력되길 기대했다. 1초, 10초, 30초, … 기다려도 그 메시지는 영원히 표시되지 않았다.
뭔가 컨텍스트를 관리하는 작업이 잘못됐나 싶어서 꼼꼼히 살펴보았으나 잘못된 부분은 없었다. 해당 코드를 인젝션에 사용하면 깔끔하게 실행됐다. 무엇이 문제일까를 한참 고민했다. 결국 원인을 알게 되었는데 생각보다 간단한 곳에 답이 있었다. Sleep, WaitForSingleObject의 문제였다. 해당 함수를 호출해서 잠긴 스레드는 제 아무리 SuspendThread, SetThreadContext, ResumeThread를 해도 깨어나지 않는다. 해당 이벤트가 시그널이 되어야 깨어날 수 있는 것이었다. 즉, 완전한 라운드 로빈 형식의 유저모드 스케줄러를 제작하기 위해서는 커널모드 객체를 사용할 수가 없다. 아니면 적어도 그것들을 전부 래핑해서 유저모드 스케줄러에 맞도록 별도의 추상화 레이어를 제공해야 된다. 물론 이것만이 문제는 아니다. tls를 사용하는 함수를 사용한다면 실제 작업이 매핑되는 스레드는 하나이기 때문에 문제가 생긴다. 이 또한 작업별로 분리해야 한다는 결론이 난다. 물론 좀 더 세부적으로 들어가면 훨씬 더 많은 문제들이 있을 것이다. 유저모드 스케줄러의 제작은 생각보다 쉬운 일이 아니었다.
윈도우에는 파이버라는 일종의 유저모드 스레드가 있다. 파이버는 스레드 시간을 나누어 실행되는 스레드보다는 좀 작은 개념이다. 그런데 이 파이버를 살펴보면 알겠지만 협업형 멀티태스킹만 구현될 수 있도록 구조가 잡혀 있다. 지금 생각해보니 이런 문제점 때문에 그렇게 만든 게 아닐까라는 생각이 든다.
0 0