스레드를 구현하는 방법은 User space 에서 구현하거나, Kernel space 에서 구현하거나, 두 방법을 섞는 하이브리드 방법 크게 3가지가 있다. 각각의 구현 방법에 대해서 정리해본다.
User Space
메모리 공간은 크게 User space 와 Kernel space 로 나뉜다.
유저 스페이스는 일반적인 유저 프로세스가 실행되는 공간이고, kernel spaces는 운영체제의 핵심 요소인 커널이 존재하며 실행되는 공간이다.
User space 에서 스레드를 구현하는 경우 위 그림과 같이 구현할 수 있다.
이때 운영체제는 실제로 멀티스레드가 가능한 환경으로 동작하지만, 커널은 스레드의 존재를 모르고, 기존의 프로세스 모델에서 프로세스를 다루는 것과 비슷하게 돌아간다. 따라서 커널에는 process table 이 존재한다.
이때 각 프로세스에서는 스레드를 지원하기 위해 스레드 테이블을 별도로 가지며, 스레드의 상태를 관리하는 별도의 런타임 시스템을 갖게 된다. 이 런타임 시스템은 스레드 관련 라이브러리를 갖고 있는 존재이며, 그 라이브러리 함수들로 지난 글에서 정리했던 create, wait, exit, yield 와 같은 프로시저를 제공한다. wait() 의 경우, 특정 스레드를 정해서 해당 스레드가 종료되면 (blocked 가 되었다거나) 다시 ready 상태로 들어가서 실행을 기다리는데, 이런 관리도 모두 런타임 시스템이 해준다.
또한 런타임 시스템이 스레드를 관리하다보니 각 스레드의 상태를 저장하는 (스택, 프로그램카운터, state, 레지스터) 스레드 테이블을 추가적으로 프로세스 내부에 가지게 된다.
(그렇다면 궁금한 점. 운영체제의 스케줄링과 런타임 시스템의 스케줄링이 서로 다를 수 있을까?
혹시 이게 스케줄링 마지막에서 정리한 policy vs mechanism 과도 관련이 있을까?)
이렇게 유저 레벨에서 구현한 스레드는 몇 가지 장점과 단점이 있다.
장점
- OS가 (커널이) 스레드를 지원하지 않더라도 구현할 수 있다.
- kernel 레벨에서 구현하는 경우보다 스레드 스위칭이 더 빠르다.
커널 레벨에서 구현하려면 trap이 발생해야 하기 때문에 시간이 조금 더 걸리지만, 유저 레벨에서 구현하면 커널을 거치지 않으므로 빠르게 스위칭 할 수 있다. 따라서 yield 와 같은 프로시저를 처리하는 경우에 더 빠르게 스레드 교체를 할 수 있다.
- 각각의 프로세스가 커스터마이징 된 스케줄링 알고리즘을 가질 수 있다.
- 스케일을 조절하기 편하다.
프로세스가 가질 수 있는 최대 스레드의 개수, 스레드 테이블의 크기 등을 커널 구현 방식에 비해 자유롭게 조절할 수 있다.
따라서 프로세스마다 스레드 테이블의 크기도 다르게 가져갈 수 있고 유연하게 사용할 수 있다.
단점
- 특정 스레드가 read와 같은 시스템 콜을 호출해서 block 되는 경우, 프로세스 전체가 blocked 상태가 되면서 다른 스레드의 동작도 멈춘다. 따라서 특정 스레드가 blocked 된 동안 다른 스레드를 돌리고자 했던 기존의 목표를 달성할 수 없게 된다.
이 문제를 해결하기 위해 몇 가지 대안이 존재하지만, 그렇게 큰 효과는 없다고 한다.
그래도 대안을 살펴보자면 다음과 같은 대안이 있다.
1. 시스템 콜 자체를 blocked 되지 않는 콜로 만든다.
예를 들어 read 와 같은 시스템 콜을 호출한다면 버퍼에 데이터가 없는 경우에는 blocked 되어야하는데, blocked 되지 않고 그냥 return 을 해버리는 것이다. (버퍼에 데이터가 있는 경우에는 버퍼에 들어있는 데이터를 가져오면 되므로 blocked 되지 않고 바로 버퍼에서 가져오면 된다.)
그런데 시스템 콜을 고친다는 것은 곧 OS를 고친다는 것과 같은데, 위 장점에서 말한 스레드를 지원하지 않는 OS에서도 스레드를 지원한다는 장점과 상충된다. (이걸 고쳐서 스레드를 지원할 바에는 그냥 운영체제가 스레드를 지원하도록 만들 수도 있는 것이다!)
2. 시스템 콜이 blocked 되는지 확인하고 만약 blocked 된다면 CPU 사용을 포기한다.
unix 에 오래된 시스템 콜 중에 select 라는 시스템 콜이 있다.
이 시스템콜을 사용하면 read 시스템콜을 호출했을 때 blocked 상태가 되는지 여부를 미리 확인할 수 있다.
이 특징을 이용하여 기존의 read() 시스템 콜을 래핑하는 새로운 read 라이브러리 함수를 다음과 같이 만든다.
read() {
while(!select) {
thread_yield();
}
read();
}
thread_yield() 같은 경우, blocked 상태가 아니라 현재 스레드를 ready 상태로 보내는 것이므로 thread_yield는 문제가 발생하지 않는다. select 시스템 콜을 호출해서 만약 blocked 상태로 빠진다고 하면 thread_yield 를 호출하여 ready 상태로 보내면, 나중에 다시 running 상태가 되었을 때 다시 while 문을 돌면서 또 select 를 체크하게 되므로 blocked 되는 문제를 회피할 수 있다.
다만 이 방법도 기존의 시스템 콜 호출 라이브러리를 래핑해야 하는 수고를 들여야 하는 단점이 있다.
user level 스레드 구현에서는 thread_yield 와 같이 blocked 상태로 빠지지 않는 경우에 대해서는 문제없이 잘 작동하는 것이 특징이다.
그러면 프로세스에 할당된 퀀텀이 20ms 일 때, 그 안에서 내부적으로 라운드 로빈 스케줄링을 돌리면서 4ms 4ms .. 로 스레드마다 퀀텀을 재할당하는 방식으로도 구현할 수 있을까?
아쉽게도 그런 방법은 구현이 힘들다. 왜냐하면 런타임 시스템이 clock 시그널을 받아들여서 그에 맞게 동작하도록 코딩하는 것이 힘들기 때문이다. (책에는 명시적으로 라운드 로빈은 '불가능하다' 라고 못 박아두었다.)
Kernel Space
이번엔 커널 레벨에서 스레드를 구현해보자.
이 그림을 보면 알 수 있듯, 이제는 커널이 스레드의 존재를 알고 있다.
따라서 커널 영역에 스레드 테이블을 추가로 두고, 커널이 스레드를 관리하도록 만든다.
별도의 런타임 시스템도 없고 유저레벨에서 추가적으로 뭔가 기능을 더할 필요도 없다.
따라서 이 방식을 사용하려면 운영체제가 스레드를 지원해야 하는 단점이 있다.
만약 운영체제가 스레드를 지원하지 않으면 user-level 스레드로 구현할 수 밖에 없다.
대신 non-blocking system call 같은 걸 구현할 필요도, read 시스템 콜에 대한 래핑과 같은 추가적인 작업도 필요하지 않다.
이 방식은 위에서 적었던 user-level 스레드의 장점을 그대로 반대로하는 단점으로 갖게 된다.
스레드 스위칭이 느리고, 스케일링이 어려우며 프로세스마다 커스터마이징된 스레드 스케줄링 알고리즘을 가질 수도 없다.
(그런데 프로세스마다 커스터마이징된 스케줄링 알고리즘을 가질 수 없다고 했지만 교수님 설명으로는 프로세스의 퀀텀이 주어졌을 때 이를 쪼개서 4ms 마다 스레드를 라운드 로빈 돌리는 것도 가능하다고 하셨다. 그러면 커널에서 구현했을 때도 프로세스마다 커스터마이징된 스레드 스케줄링 알고리즘을 가질 수 있는 것 아닌가?)
Hybrid
하이브리드 방식은 하나의 프로세스에 존재하는 스레드들에 대해서 커널 레벨에서 돌아가는 스레드가 있을 때, 이것과 별개로 유저 스페이스에서만 돌아가도 괜찮은 스레드를 동시에 같이 돌리는 방법이다.
이때는 역시 프로세스 안에 런타임 시스템이 존재해야 한다.
Scheduler Activation
유저 레벨 스레드는 스레드 스위칭이 빠르다는 정점이 있지만, blocked 시스템 콜을 호출할 때 프로세스 전체가 blocked 된다는 단점이 있었다.
스케줄러 액티배이션 방식의 구현은 이 단점을 해소하는 또 다른 방법을 제공한다.
바로 런타임 시스템을 그대로 두고 스레드 관리를 런타임 시스템에게 맡기되,
blocked 가 발생하는 시스템콜과 관련된 동작이 발생하면 커널이 이 신호를 받고 매번 런타임 시스템에게 알려주는 방법이다.
예를 들어 어떤 스레드가 돌다가 I/O 요청을 보내서 blocked 상태로 빠졌다고 해보자.
커널은 이 요청을 보낸 스레드를 알고 있으므로 런타임 시스템에게 너가 관리하는 몇 번 스레드가 지금 blocked 상태가 되었다고 알려주면, 런타임 시스템은 해당 스레드를 blocked 상태로 바꾼 뒤, 다른 스레드를 running 상태로 올린다.
만약 read가 끝나면 인터럽트가 발생하여 커널에게 알려주게 되고, 이때 커널이 다시 런타임 시스템에게 read 작업이 완료되었음을 알려주면 런타임 시스템이 blocked 된 스레드를 깨워서 ready 상태로 보낸다.
이때 커널이 런타임 시스템과 소통할 때는 런타임 시스템이 갖고있는 프로시저를 호출하여 소통하며,
아랫쪽에 있는 커널이 그 위에 있는 런타임을 호출한다고 해서 upcall 이라고도 부른다.
하지만 이 방법을 안 좋게 보는 사람들도 있다.
안정적인 아키텍처 구조라면 higher layer 에서 lower layer의 기능을 호출하는 것이 바람직한데, 그 관계가 역전되었기 때문에 소프트웨어 아키텍쳐상 좋지 않다는 것이다.
Pop-Up Thread
다시 웹서버의 예시를 생각해보자.
웹서버의 요청 처리 스레드는 요청을 기다리면서 blocked 상태에 있다가 요청이 오면 ready 상태로 올라오면서 실행이 될 것이다.
그런데 이렇게 스레드를 blocked 된 상태에서 ready 와 run 으로 올릴 때, 매번 그 상태를 저장하고 복구하는 비용이 발생한다.
팝업 스레드는 이렇게 하지말고, 그냥 요청이 들어오면 그때 새로운 비어있는 스레드를 만들자는 아이디어이다.
싱글 스레드 코드를 멀티 스레드로 만들기
기존에 작성된 싱글 스레드 기반(프로세스 모델)의 코드가 있을 때, 이 코드의 스레드를 잘개 조각내서 여러 스레드에게 할당하여 마치 멀티 스레딩 환경에서 실행하는 것처럼(스레드 모델) 만들려는 시도이다.
위 예시를 보면, 먼저 스레드 1이 access 라는 시스템 콜을 호출한다.
이 시스템 콜을 호출하면 파일에 엑세스할 수 있는지 체크해서 errno 라는 유닉스 시스템의 전역 변수에 에러 코드를 세팅한다.
만약 이때 스레드 스위칭이 발생해서 스레드 2가 open 이라는 시스템 콜을 호출했다고 하면 이 시스템콜에 의해 errno 라는 전역 변수값이 덮어써질 수 있다. (이 전역 변수는 address space 에 존재하여 스레드들에게 공유되는 변수이다.)
이때 다시 스레드1로 스위칭되면, 이 스레드가 확인하는 errno 는 예상했던 것과 다를 수 있는 문제가 발생할 수 있다.
기존의 싱글 스레드 상황이라면 문제가 발생하지 않았을 일이 멀티 스레딩으로 바뀌면서 발생하는 것이다.
그래서 이 문제를 해결하기 위해 각 스레드마다 가지는 고유 전역 변수(?)를 만들자는 아이디어도 있다.
교수님 말씀으로는 이걸 구현하려면 데이터 세그먼트 영역쪽에 이런 변수 공간을 만들어야 할 것 같다고 하신다.
(원래 프로세스 레벨에서도 전역 변수는 static 하므로 데이터 세그먼트 영역에 잡히기 때문에)
'CS > 운영체제' 카테고리의 다른 글
[운영체제] 15. 메모리 관리 (4) | 2024.12.03 |
---|---|
[운영체제] 14. 스레드 스케줄링 (0) | 2024.10.21 |
[운영체제] 12. 스레드 (0) | 2024.10.21 |
[운영체제] 11. 스케줄링 (5) - Policy vs. Mechanism (0) | 2024.10.21 |
[운영체제] 10. 스케줄링 (4) - Real-Time System 스케줄링 (0) | 2024.10.21 |