정글/Pintos

[pintos] 1주차 - 문서 정리

nkdev 2025. 5. 10. 14:45

pintos주가 시작되었다. 

어제까지 도커로 우분투 18.04 환경 세팅을 끝냈고 오늘은 프로젝트를 어떻게 진행할지에 대해 설명되어있는 문서를 정리해볼까 한다.

 

깃은 한 사람 레포지토리에 브랜치만 만들어서 관리하기로 했다.

오늘 코어타임에는 협업 컨벤션을 정하기로 했는데 이 부분이 소통과 코드 관리에 있어서 굉장히 중요한 부분이 될 것 같다. 어떻게 진행될지..! 잘 하고싶어서 오늘 커피 두 잔이나 마셨다. 집중이 잘 되는 것 같아서 좋다 👀

 

문서:

https://casys-kaist.github.io/pintos-kaist/appendix/threads.html

 

Threads · GitBook

The main Pintos data structure for threads is struct thread, declared in threads/thread.h. Represents a thread or a user process. In the projects, you will have to add your own members to struct thread. You may also change or delete the definitions of exis

casys-kaist.github.io

Understanding Threads

  • 먼저 과제에서 제공되는 스레드 시스템 코드를 읽고 이해하자. 
  • 이미 스레드 생성/종료, 스레드 스위치를 하는 간단한 스케줄러와 동기화 함수(semaphores, locks, condition variables, optimization barriers)가 구현되어있다.

 

  • 소스코드 각 부분을 꼼꼼히 읽어보며 무슨 일이 일어나고 있는지 확인해보자.
  • printf()를 찍어보면서 어떤 순서로 일이 진행되고 있는지 확인해보는 것도 좋다.
  • 궁금한 부분 마다 breakpoint를 찍고 디버거를 실행해보는 것도 좋은 방법이다.

 

  • 스레드가 생성될 때, 스케줄링의 대상이 되는 새로운 문맥(context)이 생성된다.
  • thread_create()의 인자로 실행하고자 하는 함수를 넣으면 된다. 
  • 한 번에 하나의 스레드만 실행되고, 나머지 스레드는 모두 비활성화된다.

 

  • 스케줄러는 다음에 실행할 스레드를 결정하는데 다음에 실행할 스레드가 없다면 idle 스레드를 실행한다.
  • 동기화 함수는 하나의 스레드가 다른 스레드가 뭔가를 하는 것을 기다려야할 때 문맥교환(context switch)를 강제로 진행한다.
  • 문맥 교환은 threads/thread.c의 thread_launch()에 정의되어 있다.

 

  • gdb 디버거를 사용하여 문맥 교환 시 어떤 일이 일어나는지 천천히 추적해보자.
  • schedule() 함수에 breakpoint를 설정하는 것부터 시작해서 한 단계씩 나아가보자.
  • 각각의 스레드 주소와 상태, 각각의 스레드에 대해 어떤 프로시저가 콜 스택에 있는지 주의 깊게 추적해보자.
  • 하나의 스레드가 iret과 do_iret()을 실행할 때, 또 다른 스레드가 실행을 시작하는 것을 알게 될 것이다.

 

  • 경고 : Pintos에서 각각의 스레드에는 4KB 이하의 고정 사이즈 스택이 할당된다.
  • 커널이 스택 오버플로우를 잡아내려고 노력하겠지만 잡아내기 쉽지 않다.
  • 그래서 int buf[1000];와 같이 큰 자료구조를 non-static 지역 변수로 선언한다면 Kernel panic과 같은 문제가 발생할 수 있다.
  • 대신에 스택에 할당하는 대신 page allocator와 block allocator을 사용해보자.

Synchronization

  • 동기화 문제는 인터럽트를 비활성화 시키면 쉽게 해결할 수 있지만, 그러면 동시성이 없어진다.
  • 즉 경쟁 조건의 가능성이 없어진다. 이런 식으로 모든 동기화 문제를 해결하는 방법은 매우 유혹적이지만 그렇게 하면 안 된다.
  • 대신 세마포어, 락, 컨디션 변수를 사용하여 동기화 문제를 해결하라.
  • 어떤 동기화 함수가 어떤 상황에 쓰여야 하는지 잘 모르겠다면 threads/synch.c의 코멘트 또는 동기화 섹션을 참고하라.

 

  • Pintos 프로젝트에서 인터럽트 비활성화가 최선의 해결책이 되는 문제는 딱 하나, 커널 쓰레드와 인터럽트 핸들러 사이에 공유된 데이터를 조정해야 할 때이다. 
  • 인터럽트 핸들러는 sleep상태가 될 수 없기 때문에 lock을 얻을 수 없다. 
  • 이 말은 커널 스레드와 인터럽트 핸들러 사이에서 공유되는 데이터는 인터럽트를 비활성화 시켜서만 커널 스레드에서 보호 받을 수 있다는 의미이다. 
    • 인터럽트 핸들러란 cpu가 예상치 못한 이벤트를 처리하려고 갑자기 호출되는 함수이다. 
    • 예를 들어 타이머 시간이 되었거나, 키보드를 눌렀거나 하드디스크에서 데이터가 도착했을 때 갑자기 실행되고 있던 스레드가 중단되고 인터럽트를 처리해야 한다. (cpu납치)
    • 인터럽트 핸들러는 지금 당장 실행하고 끝내야 하는 일이라서 락이 걸린 데이터를 다른 스레드가 점유하고 있을 때 기다릴 수 없다.
    • 그래서 스레드끼리 데이터를 공유할 때는 락을 써서 해결 가능하지만 스레드가 인터럽트 핸들러와 데이터를 공유할 때는 락을 쓰면 안 된다.
    • 애초에 스레드가 실행 중이면 인터럽트가 발생되지 않게 인터럽트를 disable해서 스레드와 인터럽트 핸들러가 같은 데이터에 동시에 접근할 수 없게 만들어야 한다.
enum intr_level old_level = intr_disable(); // 인터럽트 끔
shared_data++;
intr_set_level(old_level);                  // 다시 원래대로 켬

 

  • 이 프로젝트에서는 인터럽트 핸들러가 스레드 상태에 접근하는 동작이 조금은 필요할 것이다.
  • 특히 타이머 인터럽트가 자주 발생할 수 있기 때문에 그와 관련된 데이터에 커널 스레드가 접근하려면 잠깐 인터럽트를 꺼야 안전하다.
  • 알람 시계가 동작하려면 타이머 인터럽트가 sleep 상태의 스레드를 깨워야 한다.
  • 더 발전된 스케줄러에서는 타이머 인터럽트가 global 변수와 스레드 마다 생기는 지역 변수에도 접근해야 할 수 있다.
  • 커널 스레드에서 이런 공유 자원에 접근할 때는, 타이머 인터럽트가 동시에 접근하지 못하게 인터럽트를 disable시켜야 한다.

 

  • 인터럽트 비활성화는 최대한 적게 사용해야 중요한 timer ticks나 input event를 놓치지 않을 수 있다.
  • 인터럽트 비활성화 기능을 남용하면 인터럽트 작업이 자꾸 지연되어 결국 동작이 느려진다.

 

  • synch.c 내부의 동기화 함수들은 인터럽트를 비활성화하는 방식으로 구현되었다. 
  • 인터럽트 비활성화시키는 코드를 추가하게될 것인데, 최소한으로 유지하도록 주의하자.
  • 하지만 어떤 섹션의 코드가 확실히 인터럽트의 방해를 받지 않게 만들고 싶다면, 인터럽트를 비활성화시키는 것이 디버깅에 좋다.

 

  • busy waiting이 없어야 한다. thread_yield()를 콜하는 작은 루프도 busy waiting 중 하나이다. (아래 내용 참고)
더보기
더보기

busy waiting(바쁜 대기)

어떤 조건이 충족되기를 cpu를 계속 사용하면서 기다리는 것

아무것도 하지 않지만 계속 루프를 돌며 cpu를 점유하고 있으므로 낭비에 해당한다.

어떤 자원이 사용 가능해질 때까지 무한 루프를 돌면서 체크함

 

thread_yield()는 다른 스레드가 cpu를 사용할 수 있도록 양보하긴 하지만, sleep상태로 전환되지 않고 계속 깨어있는 상태로 계속 반복하며 자원이 사용 가능한지 체크하는 방식이다.

결국 조건이 만족될 때까지 계속 깨어있는 것이기 때문에 여전히 busy waiting에 해당한다.

 

Pintos에서는 busy waiting을 쓰지 않고 다음 방법을 사용해야 한다.

- semaphore (세마포어)

- lock (락)

- condition variable (조건 변수)

 

이 방법을 이용하여 어떤 스레드가 특정 이벤트를 기다려야 한다면, 조건이 충족될 때까지 sleep 상태로 뒀다가 조건이 충족되면 (signal이 오면) 다른 스레드가 깨워주는 방식으로 구현해야 한다.

 

-> 그러니까 우리는 공유 자원에 스레드와 스레드 또는 스레드와 인터럽트가 동시에 접근했을 때 busy waiting때문에 cpu가 낭비되는 부분을 고쳐야 한다.

 

sema_down() / sema_up()

lock_acquire() / lock_release()

cond_wait() / cond_signal()

Alarm Clock

  • 원래 구현되어 있는 timer_sleep() 함수는 busy wait 방식이다. 
  • 이는 계속 반복문을 돌면서 현재 시간을 확인하고 시간이 다 경과할 때까지 thread_yield()를 호출한다.
  • busy wait가 되지 않도록 다시 구현해야 한다.

 

  • void timer_sleep(int64_t ticks); 이렇게 호출되면 timer가 적어도 x번 tick해야 thread를 다시 실행한다.
  • 시스템이 idle상태(다음에 실행할 스레드가 없는 상태)라면 상관 없는데 다음에 실행해야 할 스레드가 있다면 tick이 끝날 동안 다음 스레드를 실행하면 된다.
  • tick이 덜 끝난 스레드를 sleep 상태로 만들어 ready queue에 대기하도록 한다.

 

  • timer_sleep() 함수는
    • real-time 스레드에 유용하다.
      • 예를 들면 1초에 한 번씩 깜빡이는 커서
    • 인자로 타이머의 tick 단위가 쓰인다.
      • 밀리초와 같은 단위가 아니라 TIMER_FREQ라는 초당 타이머 tick이 있음
      • TIMER_FREQ는 devices.timer.h에 정의된 매크로
      • TIMER_FREQ의 기본 값은 100이며, 값을 변경할 경우 테스트가 실패할 수 있으므로 변경하지 않는 것이 좋음
  • timer_msleep(), timer_usleep(), timer_nsleep()과 같이 밀리초, 마이크로초, 나노초 동안 sleep하는 별도의 함수가 존재하긴 하지만 이 함수들은 필요시 자동으로 timer_sleep()을 호출한다.