Home [CS] 비동기 프로그래밍 (2) - 리눅스의 프로세스, 스레드, 태스크
Post
Cancel

[CS] 비동기 프로그래밍 (2) - 리눅스의 프로세스, 스레드, 태스크

Android의 스레드 사용 방법을 알기 전에, 리눅스의 스레드와 프로세스는 어떤 원리로 작동되는 것인지, 코어가 여러 개인 CPU에서 실제로 여러 프로세스나 스레드가 병렬적으로 작동하는지 알고 싶어 이 포스트로 정리하게 되었습니다. 운영체제라는 과목에서 일반적으로 배우는 프로세스, 스레드의 개념과 리눅스에서의 프로세스, 스레드는 유사하지만 약간의 차이가 있습니다. 이 차이를 몰랐을 때 저는 왜 안드로이드 앱 개발에서 스레드 대신에 Coroutine 사용을 권장하는 것인지 의문이 있었습니다. 그냥 더 가볍고, 스레드를 적게 생성한다는 설명만으로는 이해하기 어려운 부분이 있습니다. 왜냐하면 운영체제 과목에서는 스레드를 일종의 경량 프로세스이며, 프로세스의 수행 흐름을 여러 개로 나눌 수 있다고 배웠는데 왜 경량 프로세스보다 더 가벼운 것이 필요하게 되었을지에 대한 설명은 찾을 수 없었기 때문입니다.

리눅스 커널을 사용하는 안드로이드 OS이기 때문에, 리눅스 커널에서 프로세스와 스레드의 생성과 작동 방식을 알게 된다면 위의 내용에 대한 해답을 얻는 것에 도움이 될 것이라고 생각되어 정리하였습니다. 따라서 운영체제 과목에서 배우는 프로세스와 스레드에 대한 일반적인 내용은 생략하겠습니다.

태스크

리눅스 커널에서 프로세스와 스레드를 관리하는 단위는 태스크(Task)입니다. 프로세스와 스레드가 모두 같은 task_struct라는 자료구조로 생성되고 관리됩니다. 이 자료구조는 sched.h 파일에 정의되어 있으므로 프로세스, 스레드의 생성과 관리에 있어 POSIX API인 unistd.h와 함께 많이 쓰입니다.

리눅스에서 프로세스 생성 시에는 fork()를 사용하고(이후 execl() 등으로 새로운 작업 수행), 스레드 생성 시에는 clone()이나 pthread_create() 함수를 사용합니다. 그런데 이렇게 여러 함수를 사용해도 커널 내부에서는 똑같이 do_fork() 함수를 실행하여 요청을 수행합니다.

프로세스뿐만이 아닌 스레드가 생성될 때에도 똑같이 태스크를 생성합니다. 즉 하나의 스레드가 생성될 때마다 그에 대응되는 태스크가 존재한다는 의미입니다. 이러한 방식은 리눅스 커널 2.6에서 NPTL을 채택한 이후부터 적용되었습니다. 이전까지는 사용자가 생성한 스레드의 수 그대로 커널에서 스레드를 생성한다는 보장이 없는 n:1 모델이었지만, NPTL은 사용자가 스레드 생성시 커널에서도 그와 같은 수의 스레드를 생성하고 관리하는 1:1 모델입니다.

이후에 다시 다루겠지만, 이런 관점에서 봤을 때 Coroutine의 필요성이 느껴집니다. 일시적인 작업 또는 간단한 작업을 위해 매번 새로운 스레드를 생성해서 사용한다면(물론 스레드 풀 등의 방법으로 새로운 스레드의 생성을 최대한 억제할 수도 있을 것입니다) 생성에도 시간이 필요할 것이고, 그 스레드는 제거되기 전까지 메모리를 점유할 것이며, 다른 작업으로 전환 시에 문맥 교환 작업에 시간이 소요되는 등 많은 오버헤드가 필요로 할 것입니다. 이 게시글의 첫 부분에서 적은 문제에 대해 답이 될 수 있는 부분이라고 생각됩니다. 우선 Coroutine에 대해서는 여기까지만 생각해보고, 태스크에 대해 마저 알아보도록 합시다.

그렇다면 프로세스와 스레드가 같은 자료구조로 관리된다면, 어떻게 둘을 구분할 수 있을까요? 또, 특정 스레드가 어떤 프로세스에 속한 것인지는 어떻게 알 수 있을까요? 우선 생각할 수 있는 것은 PID를 같게 하는 것입니다. 같은 프로세스니 같은 프로세스 ID가 같아도 될 것이라고 생각됩니다. 하지만 그렇다면 같은 프로세스 내의 여러 스레드인 태스크들을 구분할 방법이 없을 것입니다. 리눅스에서는 이에 대한 해결방법으로 task_struct에서 같은 프로세스의 스레드 그룹이라는 것을 알 수 있는 TGID 라는 별도의 구분값을 가지도록 하고, PID는 같은 프로세스더라도 서로 다른 값을 가지도록 합니다.

Copy-On-Write

새로운 프로세스를 생성할 때, 일반적으로 fork()를 호출하여 부모 프로세스를 그대로 복사한 후 exec()을 호출하여 프로세스를 수정합니다. 하지만 fork()의 단점을 살펴보자면, 부모 프로세스 복제에 긴 시간이 걸리고, 새로 필요하게 될 프로세스의 크기와 관련 없이 부모 프로세스의 크기만큼 메모리 영역을 필요로 하게 된다는 것입니다.

리눅스에서는 이를 개선하기 위한 방식을 도입합니다. Copy-On-Write는 새로운 태스크를 만들 때, PID 등의 일부 정보를 제외하고 부모 프로세스 태스크의 데이터를 그대로 가리키는 태스크를 만드는 기법입니다. 이렇게 하면 메모리 영역을 추가로 차지할 필요 없이, 같은 곳을 가리키기만 하면 됩니다. 그리고 새로운 값을 쓸 필요가 생기게 된다면 그 때 기존에 가리키던 영역은 그대로 두고, 새로운 메모리 영역에 값을 작성하고 그 주소를 가리키도록 하는 것입니다.

SMP

대칭형 다중 프로세서(Symmetric Multi-Processor, SMP)는 모든 CPU 코어가 대칭, 즉 동등하다는 의미입니다. 동등하다는 의미에는 각 CPU 코어가 메모리 접근에 있어서 동등한 권한을 가지고 있으며, 입출력 버스 또한 똑같이 공유합니다. 이와 대비되는 개념으로 불균일 기억 장치 접근(Non-Uniform Memory Access, NUMA)이 있지만, 여기서는 SMP에 집중하여 접근해보겠습니다.

런 큐, 스케줄러

태스크들은 CPU를 필요로 하지만, 태스크의 수에 비해 CPU 코어의 수는 매우 부족합니다. 여러 태스크가 각자 순서를 정해 돌아가면서 사용할 필요가 있을 것입니다. 실행 가능한 상태(준비 상태, 실행 상태. TASK_RUNNING)의 태스크들을 모아 적절한 순서대로 시키기 위해 리눅스에서는 런 큐 라고 하는 자료구조(rq)로 관리합니다. 이 자료구조의 구현은 위에서 task_struct의 자료구조가 정의된 sched.h 파일에 있습니다. 이러한 런 큐는 CPU의 각 코어마다 하나씩 가지게 되고, 실행 가능한 상태의 모든 태스크는 런 큐 중 한 곳에 속해 실행을 기다리게 됩니다.

새롭게 태스크가 생성되었는데 런 큐가 여러 개라면, 이 태스크는 어떤 런 큐로 가는 것이 좋을까요? 일반적으로 부모 태스크가 속한 런 큐에 삽입됩니다. 그 이유는 제가 지난 포스트에서 캐시 메모리의 지역성 원리에 대해 작성했습니다. 부모 태스크와 공유하는 데이터가 있다면 데이터 지역성의 원리를 따르게 될 것이니 성능의 이점을 가질 수 있을 것입니다.

각 런 큐가 모두 비슷한 작업량의 태스크를 가지고 있다면 좋겠지만, 시간이 지나면 각 런 큐마다 차이가 발생하게 될 것입니다. 이러한 부하에 있어서 차이가 발생하였을 때 이를 균등하게 재배치하는 부하 균등(load balancing) 기법을 사용, 런 큐 간에 태스크를 이동하여 부하를 조절합니다.

각 런 큐에서는 스케줄러가 작동해 CPU 코어에서 수행할 태스크를 정합니다. 태스크를 고르는 스케줄링의 방법은 다양하지만 이 글에서는 간단하게 일반 태스크를 위한 기본 정책만 알아보겠습니다.

CFS 스케줄러

리눅스에서 일반 태스크 스케줄링에 쓰이는 기법을 CFS(Completely Fair Scheduler) 라고 합니다. 이름에서는 완전히 공평한 것을 추구하지만, 그렇다고 해서 모든 태스크가 완전히 같은 시간동안 작업을 수행해야 한다는 것은 아닙니다. 태스크의 우선순위에 가중치를 주어 우선순위가 높은 태스크는 CPU 할당시 조금 더 긴 시간 동안 사용할 수 있도록 해 태스크가 해야 할 일을 더 빨리 끝낼 수 있도록 합니다.

이런 동작을 수행하기 위해 리눅스에서는 vruntime 이라는 개념을 도입하였습니다. 각 태스크는 vruntime을 가져 CPU를 사용한 시간과 우선순위에 따라 자신의 vruntime을 증가시킵니다. 우선순위가 높을수록(값이 낮을수록) vruntime의 증가 값이 작아지도록 합니다. 따라서 우선순위가 높으면 vruntime이 작게(느리게) 증가해 같은 vruntime을 채우기 위해서 더 긴 시간동안 CPU를 사용할 수 있는 것입니다.

스케줄러가 태스크를 고를 때 vruntime이 가장 작은 태스크를 고르면 됩니다. vruntime이 가장 작은 태스크는 가장 과거에 CPU를 사용한 것을 의미하기도 합니다. 리눅스는 가장 작은 vruntime을 가진 태스크를 고르기 위해 vruntime을 키 값으로 하는 Red-Black Tree (RBTree) 자료구조를 사용합니다.

여기서 한 가지 의문이 생깁니다. 힙 자료구조를 사용하면 최소값을 O(1)에 바로 찾을 수 있을텐데 왜 탐색에 O(log n)이 필요한 RBTree를 사용할까요? StackOverflow의 답변에 따르면, 힙 자료구조는 배열로 구현되어 있고, 따라서 연속적인 메모리 공간을 필요로 합니다. 수많은 태스크를 관리하려면 큰 배열, 즉 큰 연속적인 메모리 공간을 필요로 한다는 단점이 있기 때문입니다. 또한 힙에서 데이터의 추가, 삭제, 재정렬은 O(log n)이 필요하기 때문에 RBTree와 차이가 없고, 따라서 배열 의 문제점을 개선하는 포인터 기반의 힙을 만들어 쓰기 보다는 같은 성능을 보이는 RBTree를 쓰기로 한 것입니다.

결론적으로 각 태스크는 OS에서 효율적으로 사용 시간을 배분해주니, 애플리케이션 개발자는 필요에 따라 스레드를 나누어서 멀티스레드 프로그래밍으로 성능을 최대한 끌어올려야 할 것입니다. 다음 글에서는 Kotlin에서의 멀티스레드 프로그래밍과 Coroutine에 대한 내용을 작성하겠습니다.

참고자료

  • 리눅스 커널 내부구조(백승재, 최종무)
  • 쉽게 배우는 운영체제(조성호)
This post is licensed under CC BY 4.0 by the author.

[CS] 비동기 프로그래밍 (1) - 캐시 메모리

[CS] 비동기 프로그래밍 (3) - 동시성 프로그래밍