멀티 태스크
2025년 5월 12일
멀티태스크 #
여러 작업을 동시에(정확히 동시는 아니더라도) 실행하는게 멀티태스크 방식이라고 한다. 지금 구현된 방식도 이벤트루프에서 마우스, 키보드, 타이머 이벤트가 처리되면서 어떤 작업들이 진행되는데, 이 방식도 역시 유사 멀티태스크라고 해줄만해 보인다.
현재 방식이 멀티태스크처럼 보이는 이유는 각 작업이 빠르게 끝나서 다음 작업이 실행될 수 있기 때문인데, 어떤 작업이 아주 오래걸린다면 아마 마우스 조차 움직이지 못할 것이다.
인터럽트 메시지가 큐에 들어가는것 자체는 CPU 단에서 먼저 처리되기 때문에 수행할 수 있지만, 그 메시지가 처리될 수가 없을 것이다.
그러니까 메시지큐에 접근하며 메인루프를 돌리는 스레드까지 포함해서 작업 짧게하며 강제로 양보하도록 구현해야 한다.
멀티태스크란 어떤 작업을 진행하더라도 일정 시간이 지나면 CPU를 놓아주고 Context를 전환하여 다른 작업을 진행할 수 있게 양보하는 구조를 가져야한다.
최대한 모든 코어가 작업하고 있을 수 있도록 잘 나누는 병렬처리가 중요하고, 코어가 무한하지 않기에 모든 코어가 바빠도 유저 입장에서 동시에 동작하는 것처럼 보이도록 병행처리하는 것도 신경써야 한다.
병행처리 #
Context Switch #
CPU는 사실 그냥 레지스터에 있는 값으로 특정 명령을 실행하기만 한다. 함수 콜을 하든 점프를 하든 어떤 작업을 하든 그 명령이 CPU의 레지스터에 영향을 주는것이지, CPU가 명령을 기억하거나 동작방식이 변경되는 것은 아니다.
그냥 CPU는 설계된대로 동작할 뿐 그 위의 모든 개념은 인간이 만들어낸 추상화일 뿐이다.
Context는 CPU가 어떤 범위의 작업을 하고있는지 실행(작업) 환경의 추상화라고 할 수 있다.
CPU가 기억하는건 레지스터밖에 없으니 사실 모든 레지스터를 저장해두고 교체하면 된다.
SwitchContext 함수 #
CPU는 그저 구현된대로 명령실행 동작만 하기 때문에 직접 레지스터를 저장/복원 해서 작업의 전환을 하도록 해야한다.
1struct TaskContext {
2 uint64_t cr3, rip, rflags, reserved1; // offset 0x00
3 uint64_t cs, ss, fs, gs; // offset 0x20
4 uint64_t rax, rbx, rcx, rdx, rdi, rsi, rsp, rbp; // offset 0x40
5 uint64_t r8, r9, r10, r11, r12, r13, r14, r15; // offset 0x80
6 std::array<uint8_t, 512> fxsaved_area; // offset 0xc0
7} __attribute__((packed));
함수의 내용은 너무 길어서 간략하게 생략했지만, 그냥 현재 상태를 src TaskContext (rsi) 에 저장하고, dst TaskContext (rdi) 의 레지스터를 CPU에 세팅할 뿐이다.
일부 특수 레지스터(rflags 등)는 명령어를 이용해서 가져온다.
내용 자체는 천천히 생각하면 어렵지 않다.
1; Registers: RDI, RSI
2global SwitchContext
3SwitchContext: ; void SwitchContext(void* next_ctx, void* current_ctx);
4 mov [rsi + 0x40], rax
5 mov [rsi + 0x48], rbx
6 mov [rsi + 0x50], rcx
7 mov [rsi + 0x58], rdx
8 mov [rsi + 0x60], rdi
9 mov [rsi + 0x68], rsi
10
11 lea rax, [rsp + 8] ; call SwitchContext 로 들어왔기 때문에 rsp가 push(-8) 되어 있을 것이다.
12 mov [rsi + 0x70], rax ; RSP
13 mov [rsi + 0x78], rbp
14
15 mov rax, [rsp] ; 마찬가지로 call SwitchContext 로 들어왔기 때문에 현재 rsp는 리턴주소가 있을 것이다.
16 mov [rsi + 0x08], rax ; RIP
17 mov rax, cr3
18 mov [rsi + 0x00], rax ; CR3
19 pushfq
20 pop qword [rsi + 0x10] ; RFLAGS
21
22 fxsave [rsi + 0xc0] ; XMM0 등 의 부동소수점 레지스터 저장
23 // ...
24
25 ; iret 을 사용하기 위한 스택프레임 세팅
26 push qword [rdi + 0x28] ; SS
27 push qword [rdi + 0x70] ; RSP
28 push qword [rdi + 0x10] ; RFLAGS
29 push qword [rdi + 0x20] ; CS
30 push qword [rdi + 0x08] ; RIP
31
32 fxrstor [rdi + 0xc0]
33
34 mov rax, [rdi + 0x40]
35 mov rbx, [rdi + 0x48]
36 mov rcx, [rdi + 0x50]
37
38 // rdi는 사용중이기 때문에 맨 마지막에..
39 mov rdi, [rdi + 0x60]
40
41 o64 iret
메인 스레드? #
일단 부트로더가 커널메인을 실행할때 까지는 컨텍스트가 메인스레드 하나인 것이다. 커널메인이 실행되며 메인 루프 로직까지 실행될 것이다.
이제 CPU를 나눠 사용할 새 태스크 TaskB 를 병행처리하기에 앞서 전환하기 위한 세팅을 먼저 한다.
실행할 함수의 주소를 rip에 세팅하고, 스택을 할당한 후 rsp에 세팅한다.
스택은 거꾸로 자라기 때문에 스택의 맨 윗부분을 지정해야하고, x86-64의 스택 얼라인먼트 제약으로 인해 rsp를 잘 계산해서 세팅해야 한다.
x86-64 시스템은 call 명령을 실행하기 직전에는 스택이 무조건 16byte 정렬이 되어 있다고 확신하고 구현되어있기 때문에 일부 명령어(movaps 등)는 인자로 전달되는 메모리 주소가 16바이트 정렬이 아니면 GP(General Protection)예외가 발생하게 만들어져 있다.
그렇기 때문에 스택의 마지막 주소에서 16byte 정렬을 한 주소가 call 이전 rsp여야 하고, call 명령을 실행하면 리턴주소가 push되기 때문에 그것 까지 감안해서 8을 더 뺀 주소로 rsp를 세팅한다.
CPU는 TaskB() 함수의 앞부분도 그냥 함수 콜과 다를 부분을 못느끼기 때문에 CPU의 입장에서 rsp를 세팅해야 한다.
1// 일단 동시?에 실행시킬 TaskB 함수를 만든다.
2void TaskB(int task_id, int data) { ... }
3
4// 태스크 전환 시 저장할 레지스터들을 담을 컨텍스트
5alignas(16) TaskContext task_b_ctx, task_a_ctx;
6
7extern "C" void KernelMainNewStack(...) {
8 // TaskB 를 실행할 컨텍스트는 직접 지정해야한다.
9 std::vector<uint64_t> task_b_stack(1024);
10 uint64_t task_b_stack_end = reinterpret_cast<uint64_t>(&task_b_stack[1024]);
11
12 memset(&task_b_ctx, 0, sizeof(task_b_ctx));
13 task_b_ctx.rip = reinterpret_cast<uint64_t>(TaskB);
14 task_b_ctx.rdi = 1; // Task B 의 첫번째 인자
15 task_b_ctx.rsi = 42; // Task B 의 두번째 인자
16
17 // 페이징 테이블을 가리키는 CR3, 세그멘테이션, 플래그 레지스터 세팅
18 task_b_ctx.cr3 = GetCR3(); // 현재 값 그대로 씀
19 task_b_ctx.rflags = 0x202; // 0x200 은 IF(인터럽트)라서 세팅해야 인터럽트를 받을 수 있다.
20 task_b_ctx.cs = kKernelCS; // 현재 값 그대로 씀
21 task_b_ctx.ss = kKernelSS;
22 // x86_64 스택 얼라인먼트 제약으로 인해 16byte로 정렬하고,
23 // 리턴주소를 push한 것 처럼 8byte만큼 미리 빼둔다.
24 task_b_ctx.rsp = (task_b_stack_end & ~0xflu) - 8;
25
26 // [24:27]은 부동소수점 계산 관련 MXCSR 레지스터(4byte)인데,
27 // 부동소수점을 사용하지 않아도 세팅해야 정상적으로 동작하기 때문에 세팅함.
28 *reinterpret_cast<uint32_t*>(&task_b_ctx.fxsaved_area[24]) = 0x1f80;
이전에는 메인루프에서 처리할 인터럽트 메시지가 없을때 __asm__("hlt") 명령으로 인터럽트를 대기하고 있었지만 지금은 컨텍스트를 전환하여 TaskB에게 CPU를 양보한다.
1 // event loop
2 while (true) {
3 // ...
4 __asm__("cli");
5 if (main_queue->size() == 0) {
6 __asm__("sti");
7 SwitchContext(&task_b_ctx, &task_a_ctx);
8 continue;
9 }
Task B #
Task B Window 라는 새로운 윈도우를 생성하고, Task B 함수를 실행시켜 이 윈도우에 글자를 그리도록 한다.
루프 안에서 SwitchContext 함수를 호출해서 Task B 함수 실행 중 메인루프(Task A)로 작업을 전환하기 때문에 Task B 에게 컨텍스트 한번의 전환이 올때마다 count가 1씩 증가하게 된다.
이로써 메인루프는 메시지큐에 들어온 메시지가 없을 때 TaskB로 전환하고 TaskB는 일부 명령을 수행하고 다시 메인루프에 CPU를 양보하는 우애좋은 멀티태스크가 된다.
1void TaskB(int task_id, int data) {
2 printk("TaskB: task_id=%d, data=%d\n", task_id, data);
3 char str[128];
4 int count = 0;
5 // TaskB 관련 작업
6 while (true) {
7 ++count;
8 sprintf(str, "%010d", count);
9 FillRectangle(*task_b_window->Writer(), {24, 28}, {8 * 10, 16}, {0xc6, 0xc6, 0xc6});
10 WriteString(*task_b_window->Writer(), {24, 28}, str, {0, 0, 0});
11 // Task B의 context에서 Task A(메인스레드)의 context로 전환
12 SwitchContext(&task_a_ctx, &task_b_ctx);
13 }
14}
선점형 멀티태스킹 #
지금까지 구현된 멀티태스킹 방식은 작업을 하던 친구가 SwitchContext() 함수를 명시적으로 호출해서 다른 태스크에게 양보하는 방식이였는데, 이걸 협력적 멀티태스킹(또는 비선점형)이라고 부른다.
이 방법은 태스크중 하나라도 양보를 하지 않으면 OS 가 프리징될 수 있는 문제가 있다.
선점형 멀티태스킹은 운영체제가 특정 조건에 맞춰 강제적으로 SwitchContext 를 수행하는 방식이다.
태스크 타이머 #
운영체제가 컨텍스트를 스위칭할 어떠한 기준을 정해야한다.
여기에서는 타임 슬라이스 기반으로 0.02초마다 컨텍스트 스위칭을하기로 결정했다. (라운드로빈 스케줄러)
현재 타이머는 LAPIC를 1ms마다 인터럽트가 발생하며 Timer->Tick() 함수를 호출하며, Tick 함수 안에서 현재 tick_ 과 비교해서 전체 타이머 리스트 중 완료된 타이머가 있는지 체크하는 방식을 사용한다.
태스크용 타이머는 0.02초마다 타임아웃되며, int 최소 값을 id로 갖는다.
1const int kTaskTimerPeriod = static_cast<int>(kTimerFreq * 0.02);
2const int kTaskTimerValue = std::numeric_limits<int>::min();
3
4void InitializeTask() {
5 current_task = &task_a_ctx;
6
7 __asm__("cli");
8 timer_manager->AddTimer(
9 Timer{timer_manager->CurrentTick() + kTaskTimerPeriod, kTaskTimerValue});
10 __asm__("sti");
11}
Tick 함수에서는 TaskTimer가 끝나면 다시 같은 시간으로 등록해야한다.
일반 타이머와 달리 메시지큐를 사용하지 않고 있는데, 메시지 큐를 이용해서 SwitchTask 메시지를 처리하면 메인스레드의 루프에서 처리되어 메인스레드 -> Task B 는 잘 동작 하겠지만 Task B 에서 타이머가 완료되어 메시지 큐에 넣는다고 하더라도 메시지 처리는 메인스레드에서 하기 때문에 컨텍스트 스위치가 더이상 진행되지 않는 문제가 생긴다.
1bool TimerManager::Tick() {
2 ++tick_;
3
4 bool task_timer_timeout = false;
5 while (true) {
6 const auto& t = timers_.top();
7 if (t.Timeout() > tick_) {
8 break;
9 }
10
11 if (t.Value() == kTaskTimerValue) {
12 task_timer_timeout = true;
13 timers_.pop();
14 timers_.push(Timer{tick_ + kTaskTimerPeriod, kTaskTimerValue});
15 continue;
16 }
17
18 Message m{Message::kTimerTimeout};
19 m.arg.timer.timeout = t.Timeout();
20 m.arg.timer.value = t.Value();
21 msg_queue_.push_back(m);
22
23 timers_.pop();
24 }
25
26 return task_timer_timeout;
27}
컨텍스트 전환 #
위에서 말한 이유 때문에 Tick에서는 타이머 등록만 해서 인터럽트가 발생할때마다 SwitchContext를 해야하는지만 확인하고, 반드시 인터럽트가 끝나기 전에 컨텍스트를 전환해야한다.
Tick에서의 별도 처리로 TaskTimer가 타임아웃 됐는지 알 수 있기 때문에 거기에 맞춰 SwitchTask 를 호출하면 된다.
인터럽트 핸들러도 결국 CPU에서 실행하는 명령어일 뿐이고 핸들러 안에서 rip를 변경하면 해당되는 코드로 즉시 점프되기 때문에 NotifyEndOfInterrupt를 먼저 호출해서 끝났음을 알리고 SwitchTask를 호출해서 rip를 변경하고 원하는 TaskB 코드로 점프해야 한다.
1void LAPICTimerOnInterrupt() {
2 const bool task_timer_timeout = timer_manager->Tick();
3 NotifyEndOfInterrupt();
4
5 if (task_timer_timeout) {
6 // 그냥 내부적으로 Task A <-> Task B 를 반복하는 함수이다.
7 SwitchTask();
8 }
9}
1초마다 컨텍스트를 전환하면 TaskB가 실행되는 동안 메인스레드가 실행되지 않는 것을 쉽게 확인할 수 있고, 그동안은 당연히 마우스나 키보드 입력도 무시된다.
Task Manager #
태스크를 정말 많이 만들고 관리하기 위해서 TaskManager를 둘 것이다.
Task #
Task 는 사실상 컨텍스트랑 스택만 자체적으로 가지고 있으면 된다. 백그라운드 태스크도 있기 때문에 윈도우는 없는게 맞다.
1// 함수 포인터를 쉽게 사용하기 위해 Task의 entry 함수 타입을 정의한다.
2using TaskFunc = void (uint64_t, int64_t);
3
4class Task {
5public:
6 static const size_t kDefaultStackBytes = 4096;
7 Task(uint64_t id);
8 Task& InitContext(TaskFunc* f, int64_t data);
9 TaskContext& Context();
10
11private:
12 uint64_t id_;
13 std::vector<uint64_t> stack_;
14 alignas(16) TaskContext context_;
15};
16
17// 이전에는 수동으로 작업하던 컨텍스트 초기화를 함수로 만듦
18Task& Task::InitContext(TaskFunc* f, int64_t data) {
19 // 스택은 4096 byte
20 const size_t stack_size = kDefaultStackBytes / sizeof(stack_[0]);
21 stack_.resize(stack_size);
22 uint64_t stack_end = reinterpret_cast<uint64_t>(&stack_[stack_size]);
23
24 memset(&context_, 0, sizeof(context_));
25 context_.cr3 = GetCR3();
26 context_.rflags = 0x202;
27 context_.cs = kKernelCS;
28 context_.ss = kKernelSS;
29 context_.rsp = (stack_end & ~0xflu) - 0x8;
30 context_.rip = reinterpret_cast<uint64_t>(f);
31 context_.rdi = id_;
32 context_.rsi = data;
33
34 *reinterpret_cast<uint32_t*>(&context_.fxsave_area[24]) = 0x1f80;
35 return *this;
36}
TaskManager #
모든 Task를 저장하며 Task 의 생성과 전환을 담당한다. 전환은 current Task -> next Task 를 반복할 뿐이다.
1class TaskManager {
2public:
3 TaskManager();
4 Task& NewTask();
5 void SwitchTask();
6
7private:
8 std::vector<std::unique_ptr<Task>> tasks_{};
9 uint64_t latest_id_{0};
10 size_t current_task_index_{0};
11};
12
13TaskManager::TaskManager() {
14 // TaskManager가 생성될때 커널의 Task를 하나 만든다.
15 NewTask();
16}
17
18// 이전에는 Task a와 b만 전환했는데, task_manager가 관리하는 모든 태스크를 20ms 씩 실행시킨다.
19void TaskManager::SwitchTask() {
20 size_t next_task_index = current_task_index_ + 1;
21 if (next_task_index >= tasks_.size()) {
22 next_task_index = 0;
23 }
24 Task& current_task = *tasks_[current_task_index_];
25 Task& next_task = *tasks_[next_task_index];
26 current_task_index_ = next_task_index;
27
28 SwitchContext(&next_task.Context(), ¤t_task.Context());
29}
태스크의 스케줄링 #
지금 상태로는 모든 태스크에 20ms씩 할당해주기 때문에 태스크가 100개를 넘어가면 중요한 메인스레드도 2초마다 20ms밖에 실행하지 못하는 상황이 발생한다.
여러 프로세스를 띄워놨더라도 사용자가 실질적으로 사용하는 프로세스는 얼마 되지 않고, 나머지는 멈춰있는 상태가 되어 굳이 시간을 할당하지 않아도 될것이다.
Sleep / Wakeup #
프로세스가 처음 생성되면 Ready 상태가 되어 실행 큐에 담긴다.
실행 큐의 프로세스들은 스케줄러에 의해 CPU를 할당받아 Running 상태가 되고, 20ms간 실행 후 실행 큐로 돌아가 다음 할당을 기다린다.
실행할 필요가 없는 태스크는 Sleep 상태로 변경해서 실행 큐에서 제외되도록 하고, 다시 실행이 필요하다면 Wakeup을 호출해서 실행 큐로 돌려놓는다.
1class TaskManager {
2public:
3 TaskManager();
4 Task& NewTask();
5 void SwitchTask(bool current_sleep = false);
6
7 // 태스크를 직접적으로 컨트롤하는 슬립과 웨이크업 함수
8 void Sleep(Task* task);
9 Error Sleep(uint64_t id);
10 void Wakeup(Task* task);
11 Error Wakeup(uint64_t id);
12
13private:
14 std::vector<std::unique_ptr<Task>> tasks_{};
15 uint64_t latest_id_{0};
16 // 실행 큐. [0] 인덱스의 태스크가 현재 실행중인 태스크로 간주한다.
17 std::deque<Task*> running_{};
18};
19
20void TaskManager::SwitchTask(bool current_sleep) {
21 Task* current_task = running_.front();
22 running_.pop_front();
23 // 슬립으로 전환할 태스크가 아니라면 실행 큐의 맨 뒤에 다시 삽입한다.
24 if (!current_sleep) {
25 running_.push_back(current_task);
26 }
27 Task* next_task = running_.front();
28
29 SwitchContext(&next_task.Context(), ¤t_task.Context());
30}
31
32void TaskManager::Sleep(Task* task) {
33 auto it std::find(running_.begin(), running_.end(), task);
34 // 맨 앞에서 발견됐다는 것은 실행중인 태스크(자기자신)라는 의미이다.
35 if (it == running_.begin()) {
36 // 20ms를 다 사용하지 않고 즉시 컨텍스트 스위칭 (+실행큐에서 제거)
37 SwitchTask(true);
38 return;
39 }
40
41 if (it == running_.end()) {
42 return;
43 }
44 // 해당되는 태스크를 실행큐에서 삭제함
45 running_.erase(it);
46}
메시지 기반 태스크 스케줄링 #
태스크에서 메시지 큐를 관리하여 메시지가 들어왔을때 Wakeup 함수가 호출되며 태스크가 실행된다. 메시지가 없는 태스크들은 슬립 상태로 전환된다.
리눅스 커널에서 사용하는 wait queue 기반의 태스크(=스레드) 전환도 동일한 방식을 사용한다.
태스크에 메시지큐 추가 #
자기 자신이 처리할 메시지 큐를 멤버로 넣고, task_manager에서 태스크를 컨트롤할 수 있도록 SendMessage, ReceiveMessage를 태스크에서 구현한다.
1class Task {
2public:
3 static const size_t kDefaultStackBytes = 4096;
4 Task(uint64_t id);
5 Task& InitContext(TaskFunc* f, int64_t data);
6 TaskContext& Context();
7 uint64_t ID() const;
8 Task& Sleep();
9 Task& Wakeup();
10 void SendMessage(const Message& msg);
11 std::optional<Message> ReceiveMessage();
12
13private:
14 uint64_t id_;
15 std::vector<uint64_t> stack_;
16 alignas(16) TaskContext context_;
17 std::deque<Message> msgs_;
18};
19
20// 자신의 메시지큐에 메시지를 넣고 태스크가 실행될수 있도록 CPU를 할당(Wakeup)해준다.
21void Task::SendMessage(const Message& msg) {
22 msgs_.push_back(msg);
23 Wakeup();
24}
25
26// 메시지가 있다면 Message 리턴, 없다면 nullopt를 리턴한다.
27// task_manager에서 각 태스크에 메시지가 들어왔는지 확인하고 메시지를 처리할 수 있도록한다.
28std::optional<Message> Task::ReceiveMessage() {
29 if (msgs_.empty()) {
30 return std::nullopt;
31 }
32
33 auto m = msgs_.front();
34 msgs_.pop_front();
35 return m;
36}
메인 태스크에서 인터럽트 처리 #
메인 루프를 담당하는 태스크라고 했다. 메인 큐를 없애고 이 태스크의 메시지큐가 처리할 수 있도록 코드를 수정해야 한다.
1 // event loop
2 while (true) {
3 __asm__("cli");
4 const auto tick = timer_manager->CurrentTick();
5
6 // 이벤트 루프 안에서 main_task의 메시지 큐를 이용해야 한다.
7 // 메시지가 없다면 Sleep 상태로 두고, 메시지가 큐에 들어갈때 Wakeup이 호출될 것이다.
8 auto msg = main_task.ReceiveMessage();
9 if (!msg) {
10 main_task.Sleep();
11 __asm__("sti");
12 continue;
13 }
14 __asm__("sti");
기존에 main_queue에서 처리하던 내용을 메인 태스크의 메시지큐에서 처리하는 코드로 변경해야 한다.
인터럽트 핸들러(키보드, 마우스, 타이머 등)에서 SendMessage 로 메시지를 보내는 순간 Wakeup이 호출되며 running_ 큐에 들어가게 될것이다.
1__attribute__((interrupt))
2void IntHandlerXHCI(InterruptFrame* frame) {
3 // 1번 태스크에 메시지를 보낸다.
4 task_manager->SendMessage(1, Message{Message::kInterruptXHCI});
5 NotifyEndOfInterrupt();
6}
7
8__attribute__((interrupt))
9void IntHandlerLAPICTimer(InterruptFrame* frame) {
10 LAPICTimerOnInterrupt();
11}
12
13void LAPICTimerOnInterrupt() {
14 // Tick 함수 안에서 SendMessage 를 한다.
15 const bool task_timer_timeout = timer_manager->Tick();
16 NotifyEndOfInterrupt();
17
18 if (task_timer_timeout) {
19 task_manager->SwitchTask();
20 }
21}
태스크 우선순위 #
급하게 처리하지 않아도 되는 태스크들도 있다. 우선순위 레벨에 따라 큐를 나눠서 처리하기도 한다.
태스크에 우선순위 레벨 추가 #
태스크에서는 우선순위 레벨을 정해야하고, task_manager 에서는 우선순위에 따른 running_ 큐를 관리해야한다.
1class TaskManager {
2public:
3 // level: 0 lowest, kMaxLevel = highest
4 static const int kMaxLevel = 3;
5
6 TaskManager();
7 Task& NewTask();
8 void SwitchTask(bool current_sleep = false);
9
10 void Sleep(Task* task);
11 Error Sleep(uint64_t id);
12 // 태스크를 깨울(실행시킬) 때 어떤 레벨의 큐에 넣을지 우선순위를 지정해줘야 한다.
13 void Wakeup(Task* task, int level = -1);
14 Error Wakeup(uint64_t id, int level = -1);
15 Error SendMessage(uint64_t id, const Message& msg);
16 Task& CurrentTask();
17
18private:
19 std::vector<std::unique_ptr<Task>> tasks_{};
20 uint64_t latest_id_{0};
21 // 레벨이 0 ~ kMaxLevel 이기 때문에 레벨만큼 러닝큐를 만들어야 한다.
22 std::array<std::deque<Task*>, kMaxLevel + 1> running_{};
23 int current_level_{kMaxLevel};
24 bool level_changed_{false};
25 // 태스크의 레벨을 변경하는 함수
26 void ChangeLevelRunning(Task* task, int level);
27};
28
29// 태스크 변경 시 우선순위를 생각해서 변경해야 한다.
30// 현재 레벨의 큐를 모두 비우고 다시 높은 우선순위 큐를 찾아서 처리하도록 구현했다.
31void TaskManager::SwitchTask(bool current_sleep) {
32 // 현재 레벨에 맞는 큐 가져옴
33 auto& level_queue = running_[current_level_];
34 // 태스크를 꺼냄 (현재 실행중인 태스크)
35 Task* current_task = level_queue.front();
36 level_queue.pop_front();
37
38 if (!current_sleep) {
39 level_queue.push_back(current_task);
40 }
41 // 비어있는 경우 레벨 변경 필요
42 if (level_queue.empty()) {
43 level_changed_ = true;
44 }
45
46 if (level_changed_) {
47 level_changed_ = false;
48 // 레벨 변경이 필요한 경우 가장 높은 우선순위 큐부터 확인한다.
49 for (int lv = kMaxLevel; lv >= 0; --lv) {
50 if (!running_[lv].empty()) {
51 current_level_ = lv;
52 break;
53 }
54 }
55 }
56
57 Task* next_task = running_[current_level_].front();
58 SwitchContext(&next_task->Context(), ¤t_task->Context());
59}
Sleep / Wakeup #
Sleep과 Wakeup 코드도 레벨큐를 적용해야 한다.
1void TaskManager::Sleep(Task* task) {
2 if (!task->Running()) { // 이미 슬립이라면 작업 안함
3 return;
4 }
5
6 task->SetRunning(false);
7 // 현재 실행중인 자기자신이라면 태스크를 전환하면서 슬립
8 if (task == running_[current_level_].front()) {
9 SwitchTask(true);
10 return;
11 }
12 // 자기자신이 아니면 그냥 지우면 끝이다.
13 Erase(running_[task->Level()], task);
14}
15
16// 얘는 자기 자신일수가 없다. CPU 점유도 없이 코드가 실행될 수 없으니까
17void TaskManager::Wakeup(Task* task, int level) {
18 // 태스크가 이미 running_ 큐에 있다면 레벨만 변경한다.
19 if (task->Running()) {
20 ChangeLevelRunning(task, level);
21 return;
22 }
23 // 인자로 받은 level 이 0보다 작다면 기존 레벨 유지
24 if (level < 0) {
25 level = task->Level();
26 }
27 task->SetLevel(level);
28 task->SetRunning(true);
29
30 // 태스크를 해당되는 레벨 큐에 넣고, 만약 현재 실행중인 레벨보다 우선순위가 높다면
31 // 다음 SwitchTask 에서 실행할 큐 레벨을 변경해서 이 태스크를 실행시키도록 한다.
32 running_[level].push_back(task);
33 if (level > current_level_) {
34 level_changed_ = true;
35 }
36 return;
37}
태스크의 레벨 변경 #
Wakeup 할 때 사용되는 태스크의 레벨을 변경하는 함수이다. running일때만 호출된다.
지정한 태스크의 레벨이 변경될 때 현재 실행중인 레벨보다 우선순위가 더 높게 변경되면 그 태스크를 다음 switch에서 우선 실행하도록 level_changed_ 값을 true로 세팅한다.
1void TaskManager::ChangeLevelRunning(Task* task, int level) {
2 // 레벨을 변경할 필요가 없는경우
3 if (level < 0 || level == task->Level()) {
4 return;
5 }
6 // 맨앞이 아니라면 자유롭게 큐에서 꺼내서 원하는 레벨에 넣는다.
7 // CPU가 실행중인 태스크 자기 자신이 아니라는 의미이다.
8 if (task != running_[current_level_].front()) {
9 Erase(running_[task->Level()], task);
10 running_[level].push_back(task);
11 if (level > current_level_) {
12 level_changed_ = true;
13 }
14 return;
15 }
16
17 // 만약 자기자신이라면 큐를 이동시킨다.
18 // 어차피 컨텍스트는 적당히 작업 끝나면 전환되니까 무시해도 된다.
19 // 현재 실행중인 태스크이기 때문에 task_manager에서도 현재 실행중인 큐니까
20 // current_level_ 도 수정해줘야 한다.
21 running_[current_level_].pop_front();
22 // 현재 실행중이였기 때문에 맨앞에 추가된다.
23 running_[level].push_front(task);
24 task->SetLevel(level);
25 if (level >= current_level_) {
26 current_level_ = level; // 현재 레벨이 높아짐
27 } else {
28 current_level_ = level; // 현재 레벨이 낮아짐
29 level_changed_ = true; // 레벨이 낮아졌다면 SwitchTask에서 낮은레벨을 실행하게 되기 때문에 true로 변경해야한다.
30 }
31}
유휴 태스크 추가 #
running_ 큐에 아무런 태스크도 없는 경우 SwitchTask 가 호출되면 크래시가 발생할 것이다.
메인태스크도 메인루프에서 메시지가 없다면 Sleep 상태로 전환되는데, TaskTimer 인터럽트에서 시간이 되면 SwitchTask를 호출하기 때문에 running_ 큐가 비어있을때 SwitchTask가 호출되게 된다.
이걸 방지하기 위해 절대 슬립되지 않는 유휴 태스크를 추가하고, 이 태스크가 실행될때 hlt로 멈추기 때문에 타이머인터럽트가 발생될 때마다 자기자신과 SwitchTask 를 호출하게 되고 Switch 이후에는 hlt를 실행시켜 CPU가 쉴 수 있게 된다.
1TaskManager::TaskManager() {
2 Task& task = NewTask()
3 .SetLevel(current_level_)
4 .SetRunning(true);
5 running_[current_level_].push_back(&task);
6
7 // hlt만 실행하는 유휴 태스크.
8 Task& idle = NewTask()
9 .InitContext(TaskIdle, 0)
10 .SetLevel(0)
11 .SetRunning(true);
12 // 0번 우선순위는 유휴태스크 전용이다. (가장 낮은 우선순위)
13 running_[0].push_back(&idle);
14}
실행할 태스크가 없는 경우 유휴태스크만 계속 컨텍스트가 전환되는 것을 볼 수 있다.
TaskB 가 sleep되지만 않는다면 유휴태스크가 우선순위가 낮기 때문에 TaskB만 실행되고 더 낮은 우선순위를 가진 유휴태스크는 TaskB가 양보해줄 때 까지 실행되지 않는다.
Draw는 메인태스크에 요청 #
지금 구현대로 하면 TaskB 에서 layer_manager->Draw 를 직접 호출하여 자신을 그리는 작업은 직접 수행한다.
마우스를 이용해서 TaskB의 윈도우를 움직였을때는 인터럽트 핸들러에서 메시지를 보내 메인 태스크에서 메시지를 받고 ProcessEvents 를 거쳐 등록한 default_observer를 호출해서 마우스 버튼 상태에 따라 동작을 수행한다.
이렇게 두 태스크에서 TaskB의 Draw를 처리하다 보니 프레임 백퍼버에 한쪽이 쓰다가 컨텍스트가 전환되어 다른쪽이 동시에 사용하는 경우가 생기고 그림에 잔상이 남는 상황이 발생한다.
그렇기 때문에 모든 Draw 요청은 메시지큐를 통해 메인 태스크에서만 진행하도록 구현해야 한다.
메인 태스크에서 kLayer 메시지를 전달받으면 ProcessLayerMessage 함수를 호출한다.
1 // event loop
2 while (true) {
3 switch (msg->type) {
4 // ...
5 case Message::kLayer:
6 // 들어온 Draw 메시지를 처리한다.
7 ProcessLayerMessage(*msg);
8 __asm__("cli");
9 // Draw를 요청한 task에 완료됐다고 통지한다.
10 task_manager->SendMessage(msg->src_task, Message{Message::kLayerFinish});
11 __asm__("sti");
12 break;
13 default:
14 Log(kError, "Unknown message type: %d\n", msg->type);
15 }
16 }
17
18// 이 함수 안에서 layer_manager->Draw 를 대신 호출해준다.
19// 이 함수가 실행되는 곳은 메인루프이기 때문에 항상 메인스레드가 그리게된다.
20void ProcessLayerMessage(const Message& msg) {
21 const auto& arg = msg.arg.layer;
22 switch (arg.op) {Add commentMore actions
23 case LayerOperation::Move:
24 layer_manager->Move(arg.layer_id, {arg.x, arg.y});
25 break;
26 case LayerOperation::MoveRelative:
27 layer_manager->MoveRelative(arg.layer_id, {arg.x, arg.y});
28 break;
29 case LayerOperation::Draw:
30 layer_manager->Draw(arg.layer_id);
31 break;
32 }
33}
TaskB에서는 count 계산이 끝나면 메인태스크에 Draw를 요청하고, Draw 완료 응답 메시지가 올때까지 무한루프 안에서 대기한다.
1void TaskB(uint64_t task_id, int64_t data) {
2 printk("TaskB: task_id=%lu, data=%lx\n", task_id, data);
3 char str[128];
4 int count = 0;
5 // 결국엔 태스크의 전환도 TaskTimer의 인터럽트에서 발생하기 때문에 인터럽트를 막아둬야한다.
6 // 막지 않으면 태스크가 전환되어 다른 태스크가 리턴될 수 있다.
7 __asm__("cli");
8 Task& task = task_manager->CurrentTask();
9 __asm__("sti");
10
11 while (true) {
12 ++count;
13 sprintf(str, "%010d", count);
14 FillRectangle(*task_b_window->Writer(), {24, 28}, {8 * 10, 16}, {0xc6, 0xc6, 0xc6});
15 WriteString(*task_b_window->Writer(), {24, 28}, str, {0, 0, 0});
16
17 // 메인태스크에 Draw를 요청하는 메시지를 보낸다.
18 Message msg{Message::kLayer, task_id};
19 msg.arg.layer.layer_id = task_b_window_layer_id;
20 msg.arg.layer.op = LayerOperation::Draw;
21 __asm__("cli");
22 task_manager->SendMessage(1, msg);
23 __asm__("sti");
24
25 // 응답을 대기하는 루프.
26 while (true) {
27 __asm__("cli");
28 // Sleep 상태로 대기한다. 메시지가 도착하면 Wakeup이 호출되어 깨어난다.
29 auto msg = task.ReceiveMessage();
30 if (!msg) {
31 task.Sleep();
32 __asm__("sti");
33 continue;
34 }
35 // 만약 원하는 메시지가 왔다면 break로 탈출
36 if (msg->type == Message::kLayerFinish) {
37 break;
38 }
39 }
40 }
41}