[OS] User-level 쓰레드 VS 커널 쓰레드, Go Language

2024. 4. 12. 18:15CS/Operating System

커널/유저 레벨 쓰레드

쓰레드는 누가 생성하고 관리할까?

  • OS(커널)에서 시스템 콜을 통해 쓰레드를 생성하고 관리한다.
    • pthreads도 OS가 관리하는 쓰레드이다.
  • 유저 레벨 프로세스에서 라이브러리를 통해 쓰레드를 관리할 수 있다.

어떤 쓰레드가 더 좋을까?

 

예를 들어서 생각해 보자. 팩토리얼을 재귀로 구현하면 함수가 계속 불리고, 함수를 시작하고 끝내기 위해 그만큼 어셈블리어 코드가 몇 줄씩 들어가게 된다. 따라서 성능적인 측면에서 반복문을 사용하는 방식이 좋다.

 

또, local function으로 myswap() 같은 걸 만들 수 있다. 또 library function으로 strcpy() 같은 것도 있고, system call에도 getpid() 같은 게 있다. 만약 세 함수가 모두 같은 역할을 한다면 어떤 게 가장 좋을까?

→ 결론은 local function이 가장 빠르다.

 

system call을 부르면, trap이 걸리고 exception이 발생해서 커널 모드로 변경되고 system call 핸들러가 system call을 처리한 후 다시 애플리케이션에 돌려주는 과정이 일어난다. 즉, protection boundary를 넘어가는 과정이 일어난다.

 

library function을 부르면 shared library를 찾아서 실행하고 다시 돌아와야 한다. 만약 static library를 사용한다면 용량은 커지겠지만 local function과 같은 시간이 발생할 것이다.

 

local function은 그냥 내 코드 중에서 실행하는 것이므로 별로 overhead가 없다.

 

이런 예시를 보면, 커널 레벨로 전환했다가 다시 돌아오는 건 오버헤드가 발생한다. 

 

kernel threads를 관리하는 일은 system call을 통하므로 오래 걸린다. 쓰레드를 user-level에서 관리하면 마치 local function의 예시처럼 더 빠르다.

 

유저 레벨에서 쓰레드의 관리가 가능한 이유는 뭘까?

  • 쓰레드는 같은 address space를 공유한다. 따라서 쓰레드는 address spacce를 조작할 필요가 없다.
  • 또, 쓰레드는 PC, SP, 레지스터 등의 하드웨어 context만 다르다. 이 context 들은 유저 레벨 프로세스에서 조작할 수 있다.

커널 쓰레드

  • OS는 쓰레드와 프로세스를 관리한다.
  • 모든 쓰레드 연산은 커널에 구현되어 있다.
  • OS가 시스템의 모든 쓰레드를 스케줄링한다.
    • 만약 I/O 등의 대기 중인 프로세스의 스레드가 있다면 OS는 그것을 알고 그 프로세스의 다른 쓰레드를 실행시킨다.
  • 커널 쓰레드는 프로세스보다 싸다.

커널 쓰레드의 한계

  • 여전히 비싸다.
    • system call을 사용하지 않고 더 싸고 빠르게 동작하기를 원한다.
  • 프로세스보단 훨씬 가볍지만, 쓰레드 연산은 모두 system call이다. 
  • 커널은 각 쓰레드의 정보를 유지해야만 한다. 따라서 쓰레드의 총개수가 정해져 있다.

User-level Threads

  • 쓰레드를 더 싸고 빠르게 만들기 위해서는, 그들을 유저 레벨에서 구현해야 한다.
  • Portable : 유저 레벨 쓰레드는 완전히 런타임 시스템(user-level library)에 의해 관리된다. 즉, 운영 체제와는 독립적이다.

이런 이유에서 유저 레벨 쓰레드가 등장했다. 유저 레벨 쓰레드는 작고 빠르다!

  • 각 쓰레드는 PC, registers, stack 그리고 small thread control block(TCB)로 나타나진다
  • 쓰레드를 생성하고 쓰레드들 간의 동기화는 커널이 아닌 프로시저 calls에 의해 이루어진다.
  • 유저 레벨 쓰레드는 커널 레벨에 비해 약 10~100배 빠르다.

User-level Threads의 구현

이 사진을 잘 이해해 보자. 커널 쓰레드는 커널 코드 내부에 쓰레드 table이 있었다. 하지만, 유저 레벨 쓰레드에서는 런타임 시스템이 쓰레드 table을 관리하고 그 크기도 훨씬 작다.

 

유저 레벨 쓰레드 Context Swtich

  • 현재 실행 중인 쓰레드의 context를 저장한다.
    : 그것의 스택에 machine state를 모두 push 한다.
  • 다음 쓰레드의 context를 복구시킨다.
    : 다음 쓰레드의 스택에서 machine state를 pop 한다.
  • 다음 쓰레드가 현재 쓰레드가 된다.
  • 새로운 쓰레드로 caller에게 돌아간다.
    : 다음 쓰레드의 PC 값으로 실행을 재개한다.

유저 레벨 쓰레드는 커널 쓰레드에 비해 Context Switch가 매우 간단하다.

 

User-level Threads 한계점

  • 유저 레벨 쓰레드는 운영체제 내부에서 볼 수 없다.
  • 따라서 운영체제가 이상한 결정을 내릴 수 있고 이는 CPU의 낭비로 이어진다.
    • 할 일이 없는 쓰레드만 가진 프로세스를 스케줄링하는 것
    • I/O를 시작한 쓰레드를 가진 프로세스 전체를 막는 것, 실행할 준비가 된 다른 쓰레드가 있어도 막아버려서 문제가 된다.
    • 락을 들고 있는 쓰레드를 가진 프로세스를 스케줄링하지 않는 것
  • 이러한 문제를 해결하기 위해선 커널과 유저 레벨 쓰레드의 매니저간의 협력이 필요하다.
    • 예를 들어, OS의 차단 system call은 호출된 쓰레드가 해당 작업이 완료될 때 까지 block되며 프로세스 전체를 block한다. 따라서 이러한 차단을 피하기 위해 라이브러리에서 커널의 차단 시스템 콜을 대신하여 비차단 호출을 사용해야 한다.

 

User-level Threads의 스케쥴링과 관련된 이슈

어떤 쓰레드가 CPU를 계속 쓰면 어떻게 해야 할까? 

프로세스의 경우에는 타이머 인터럽트를 통해서 컨텍스트 스위치가 일어났다. 타이머가 없었다면 커널 코드를 실행할 방법이 없다. 한 프로세스의 코드를 계속 가리키면서 실행하기 때문이다.

유저레벨 쓰레드에서는 어떻게 가능할까?

  • Non-preemptive scheduling 방식
    • yield() 콜을 부르면 CPU를 내놓게 된다. 그럼 다른 쓰레드가 사용하게 된다. 이 방식은 cooperate 해야 한다. yield() 이후에 다른 쓰레드가 돌아가고 또 yield()가 호출되어야 한다. 스스로 양보하는 느낌이고 두 쓰레드가 서로 양보해야 둘 다 코드가 돌아간다. 
      yield()가 다시 호출되지 않으면 호출하지 않은, 그 코드만 계속 돌아간다.\
  • preemptive scheduling 방식
    • 프로세스의 컨트롤을 asynchronously 하게 다시 얻어올 필요가 있다.
    • 스케줄러는 OS에게 타이머 인터럽트를 전달받기를 주기적으로 요청한다.
      • 보통 UNIX signal에 의해 전달된다.
      • 시그널은 software interrupts처럼 동작한다. 하지만 하드웨어가 아닌 OS에 의해 전달된다.
      • 즉, OS가 타이머 인터럽트를 받으면 어플리케이션 계층으로 시그널을 보내고, 시그널 핸들러가 작동해서 CPU 스케쥴링이 일어나는 것이다.
        그래서 시그널을 software interrupts라고도 한다. 이 경우에는 당연하게도 운영체제가 시그널이라는 메커니즘을 지원해야 한다.

Go Lang

  • Go 언어는 쓰레드만을 위해 만들어진 언어이다.
  • 구글 infra strucure에서는 굉장히 많은 서버가 있었고, 원래 C++을 사용했다. 그런데 너무 불편하고 컴파일이 오래 걸려서 Go 언어를 만들었다.
  • Go는 쓰레드에 최적화되어 있어서 goroutine을 사용해서 내부에서 user-level thread를 지원한다.