Pintos 같은 경우 관련 참고서로 삼기 위한 GitBook이 있다
Pintos 처음은 스레드를 구현해야 하는데
thread를 들어가기전
동기화(Synchronization)를 알아야 한다고 한다
스레드 간 자원 공유는 중요하기에 이를 통제하고 처리하는 방식에 대해 나온다고 하며
Pintos는 이를 위해 동기화 원시(primitive) 를 제공해준단다
아마 말그대로 원시적인 동기화 방법들을 의미하는 것 같다
가장 무식한 방법은 인터럽트 비활성화다
말 그대로 CPU가 인터럽트에 응답 못하도록 일시적으로 막는 방식이다
인터럽트를 막음으로써 타이머 인터럽트가 스레드 선점(preempt) 못하게 한다
비활성화 안해 놓으면 언제든 선점 당할 수 있다는 거고 말이다
이를 통해 Pintos가 선점형 커널(preemptible kernel) 이란 걸 알 수 있다
비선점형(nonpreemptible) 이었으면 커널 스레드는 스케줄러를 명시적으로 호출하는 지점에서만 선점 가능해
선점형 보다 덜 명시적이어도 괜찮다
자꾸 선점선점 거리는데
선점(preempt)이 뭐냐?
말 그대로 선점이다 ㅇㅇ
CPU는 하나한테 자원 쓰기에 여럿에게 못쓴다
그러니 이를 돌아가며 할당해 주는데
OS가 실행 중인 스레드/프로세스에게서 CPU를 빼앗아 다른애 주는 걸 선점(Preempt) 이라 한다
참고로 다른애는 준비(ready) 스레드다
뺏기는 대신 스스로 내주는 경우도 있는데 이는 비선점이라 한다
아무튼, 일반적으로 인터럽트 이렇게 직접 건드리는 일은 별로 없다
다른 원시적 동기화 쓴다
인터럽트 비활성화를 쓰는 이유는…
커널 스레드와 외부 인터럽트 핸들러를 동기화
를 위해서란다
이게 무슨말이냐?
커널 스레드:
커널이 스케줄링 할 때의 실행 단위다
커널 주소공간과 커널 스택 을 쓰며, 블록(잠들기)·깨우기·우선순위 같은 스케줄링의 대상이 된다
외부 인터럽트:
하드웨어가 지금 처리하라고 CPU에 비동기 신호 보내는 걸 의미한다
이때, 인터럽트 핸들러(ISR) 를 실행한다
외부 인터럽트가 들어왔을때 기존에 돌아가던 거 잠시 멈추고 할 거 하고 빠져야하는데
타이머 인터럽트는 꾸준히 시간마다 애들을 깨우고 실행하고 이러한 걸 반복한다
이럴 때 같이 말려들어가거나 같은 자료구조를 외부 인터럽트가 건드리면 에러 터진다
이를 막기 위해 쓴다는 거다
즉, 커널 스레드가 공유 자료구조 잠시 만지는 동안 로컬 CPU의 인터럽트를 잠시 막아 원자성을 보장한다라 할 수 있다
Pintos로 이를 구현할때에 인터럽트 제어용 타입과 함수 목록이다
include/threads/interrupt.h
에 있다고 한다
enum intr_level;
인터럽트 상태를 나타내며 INTR_OFF(꺼짐) 또는 INTR_ON(켜짐) 중 하나입니다.
enum intr_level intr_get_level (void)
현재 인터럽트 상태를 반환합니다.
enum intr_level intr_set_level (enum intr_level level);
level 값에 따라 인터럽트를 켜거나 끕니다. 이전 인터럽트 상태를 반환합니다.
enum intr_level intr_enable (void);
인터럽트를 켭니다. 이전 상태를 반환합니다.
enum intr_level intr_disable (void);
인터럽트를 끕니다. 이전 상태를 반환합니다.
세마포어(semaphore) 는 음수가 될 수 없는 정수와 그것을 원자적으로 조작하는 두 연산으로 이루어진 원시적 동기화라고 한다
흠…
이해 못했다
그놈의 원자적이 뭔지부터 알아보자
원자성, 원자적 등등 컴퓨터 주제에 원자 더럽게 좋아한다
원자성: 쪼개지지 않는 한번의 동작
흑백 논리마냥 성공/실패 둘 중 하나만 보이게 만드는 성질이라고 한다
도중에 끼어들거나 침범당해 중간 상태 같은게 업도록 말이다
즉, 원자적으로 조작이란 말은 도중에 끼어들기가 불가능하게 실행된다는 뜻이다
세마포어의 두 연산:
Down / P: 값이 양수가 될 때까지 기다렸다가, 값을 1 감소
Up / V: 값을 1 증가(그리고 대기 중인 스레드가 있다면 하나를 깨움)
이를 어떻게 활용하냐?
초기값에 따라 갈린다
초기값이 0일 경우 정확히 한 번 발생할 이벤트를 기다리는 데 사용할 수 있다
A가 B를 시작하고 B가 끝냈다는 신호를 기다리고 싶다하면
A가 0으로 초기화된 세마포어를 만들어 B에게 전달한다
전달 후, sema_down()
을 호출하고
B가 작업을 마치면 sema_up()
을 호출한다
이러면 up
된다음 down
이 되기에
A가 먼저 down
하든 B가 먼저 up
하든 문제 없이 잘 작동한다
이거 좀 재밌는 아이디어 같다
초기값이 1인 세마포어틑 자원 접근 제어에 쓰인다고 한다
자원 사용전에 down
으로 잠그고, 사용 후 up
으로 풀어주는 식이다
이거 쓸바엔 락(lock)이 더 적절할때가 많을 거라한다
세마포어를 1보다 높은 숫자로 초기화 가능하다만 보통 안그런다고 한다
Pintos의 세마포어 타입과 연산은 include/threads/synch.h
에 선언되어 있다
struct semaphore;
세마포어 타입
void sema_init (struct semaphore *sema, unsigned value);
초기화
void sema_down (struct semaphore *sema);
down/P: 양수까지 기다렸다 1 감소
bool sema_try_down (struct semaphore *sema);
대기 없이 시도, 성공 시 true
void sema_up (struct semaphore *sema);
up/V: 1 증가, 대기자 하나 깨움
(대부분의 원시적 동기화와 달리 sema_up()은 외부 인터럽트 핸들러 안에서도 호출할 수 있다)
초기값이 1인 세마포어랑 유사하다
세마포어의 up
에 해당하는 연산을 release
세마포어의 down
에 해당하는 연산을 acquire 라고 한다
그럼 세마포어와의 차이점이 뭐냐?
락에는 중요한 제약이 존재 한다
락을 획득한 스레드(소유자) 만이 그 락을 해제할 수 있다
이거 싫으면 세마포어 쓰는게 더 낫다
락을 소유한 스레드가 같은 락 다시 획득하려 하면 오류다
락 관련 타입과 함수는 include/threads/synch.h
에 선언되어 있다
struct lock;
락 타입
void lock_init (struct lock *lock);
초기화 (초기 소유자 없음)
void lock_acquire (struct lock *lock);
현재 스레드가 락 획득(필요 시 대기)
bool lock_try_acquire (struct lock *lock);
대기 없이 시도, 성공/실패 반환
void lock_release (struct lock *lock);
락 해제(현재 스레드가 소유자여야 함)
bool lock_held_by_current_thread (const struct lock *lock);
현재 스레드가 소유자인지
모니터(monitor) 는 세마포어나 락 보다 한 단계 더 뛰어넘은 동기화 개념이다
인터럽트 비활성화가 1단계 셀이고 세마포어랑 락이 2단계라면…
모니터는 ⌜퍼펙트 셀⌟ 이다
모니터는 보호할 데이터, 모니터 락, 그리고 하나 이상의 조건 변수(condition variable) 로 구성되어 있다
보호된 데이터를 접근하기 전에 스레드는 먼저 모니터 락을 획득
그렇게 할 경우 모니터 안에 들어온(in the monitor) 상태가 된다
안에 들어오고 나서는 배타적 권한을 가지게 되고
마음대로 조사하고 마음대로 수정한다… 가 가능케 된다
접근 종료 시 모니터 락도 해제 한다
조건 변수는 모니터 내부 코드가 어떤 조건이 참이 될 떄까지 기다릴 수 있게 해준다
각 조건 변수는 추상적 조건과 연관된다 (예: 처리할 데이터 도착, 마지막 키 입력 후 10초 경과 등등)
조건이 필요하지만 아직 참이 아니면, 스레드는 해당 조건 변수에서 wait
하여 락을 잠시 내려놓고(release) 신호를 기다린다
반대로 어떤 조건을 참으로 만들었다면, signal
(대기자 하나 깨움) 또는 broadcast
(모든 대기자 깨움)를 호출한다
조건 변수 타입과 함수는 include/threads/synch.h
에 선언되어 있다
struct condition;
조건 변수 타입
void cond_init (struct condition *cond);
초기화
void cond_wait (struct condition *cond, struct lock *lock);
원자적으로 lock
을 내려놓고 cond
신호를 기다린 뒤, 깨어나면 lock
을 다시 획득하여 반환
(호출 전 반드시 lock
을 잡고 있어야 함)
신호 보내기와 깨어남은 원자적이지 않으므로, 보통 깨어난 뒤 조건을 재확인하고 필요하면 다시 기다려야 합니다.
void cond_signal (struct condition *cond, struct lock *lock);
대기 스레드가 있다면 그 중 하나를 깨움
없다면 아무 일도 하지 않음 (호출 전 lock
보유 필요)
void cond_broadcast (struct condition *cond, struct lock *lock);
대기 중인 모든 스레드를 깨움 (호출 전 lock
보유 필요)
모니터 예시 ```c char buf[BUF_SIZE]; /* 버퍼 / size_t n = 0; / 0 <= n <= BUF_SIZE: 버퍼 내 문자의 개수 / size_t head = 0; / 다음에 쓸 위치 (모듈로 BUF_SIZE) / size_t tail = 0; / 다음에 읽을 위치 (모듈로 BUF_SIZE) / struct lock lock; / 모니터 락 / struct condition not_empty; / 버퍼가 비어 있지 않음 / struct condition not_full; / 버퍼가 가득 차지 않음 */
/* … 락/조건변수 초기화 … */
void put (char ch) { lock_acquire (&lock); while (n == BUF_SIZE) /* 가득 찬 동안은 못 씀 / cond_wait (¬_full, &lock); buf[head++ % BUF_SIZE] = ch; / ch를 버퍼에 추가 / n++; cond_signal (¬_empty, &lock); / 이제 비어 있을 수는 없음 */ lock_release (&lock); }
char get (void) { char ch; lock_acquire (&lock); while (n == 0) /* 비어 있는 동안은 못 읽음 / cond_wait (¬_empty, &lock); ch = buf[tail++ % BUF_SIZE]; / 버퍼에서 ch를 읽음 / n–; cond_signal (¬_full, &lock); / 이제 가득 찰 수는 없음 */ lock_release (&lock); }
<br><br>
# 최적화 장벽 (Optimization Barriers)
드디어 마지막이다
새벽 3시 한창 지난지라 피곤하다...
**최적화 장벽(optimization barrier)** 은 컴파일러가 장벽을 기준으로 메모리 상태에 대해 가정하지 못하게 만드는 특별한 구문이다
파일러는 장벽을 넘어 **읽기/쓰기의 재배치**를 하지 않고, **주소가 한 번도 취해지지 않은 지역 변수**를 제외하면 변수 값이 그대로일 것이라고 가정하지 않는다
아무래도 저 두개는 확실히 그대로일테니...
아무튼, 장벽이 하는 역할은 컴파일러에게 **“이 지점을 경계로 메모리 상태를 함부로 가정하거나 읽기/쓰기 순서를 바꾸지 마”** 라고 알려주는 표식이다
`barrier()`가 이거 해준다
간단히 정리하자면 이를 통해
1. **코드 재배치:** 장벽 앞뒤의 메모리 접근 순서가 뒤바뀌는 것
2. **죽은 코드/루프 제거:** 바쁜대기 루프를 “효과가 없다”며 없애버리는 최적화
3. **값 고정 가정:** 외부에서 바뀔 수 있는 전역/공유 변수를 “안 바뀐다”고 가정
을 막을 수 있다
<br><br>
이와 관련해 예시들 나오는데 다 그냥 그런거다
컴파일러가 함부로 판단내리지 않게 하는 거 말이다
무한 루프 아니라고 알려주거나 필요한 루프라고 알려주기도 하며<br>
메모리 읽기/쓰기의 순서 강제 등을 한다는 예시 말이다
이런 장벽을 대체하는 방법으로<br>
인터럽트 차단이란 `volatile`이라는 거 알려주는데
인터럽트 차단은 끄고 키는 비용 발생하고<br>
`volatile`은 의미가 애매하며 일부 최적화만 제한하고 순서 보장도 부족해 Pintos에선 안쓴다
쓰는 법은 걍 `barrier();` 끼워넣으면 된다<br>
뭐 인자도 반환값도 없다
## 간단 예시
- **바쁜 대기 루프** 내부
```c
while (ticks == start)
barrier(); // 루프를 없애거나, 읽기/쓰기 순서를 바꾸지 못하게
의도한 쓰기 순서 강제
data_ready_value = val;
barrier(); // data를 먼저 썼다는 사실을 컴파일러에 보장
ready_flag = true;
무해해 보이는 루프의 삭제 방지
while (loops-- > 0)
barrier(); // “효과 없음” 최적화로 통째 삭제되는 것 방지
마지막으로 간단히 요약하자면 barrier
는
장벽 앞뒤로 메모리 접근을 재배치하지 말라, 메모리가 바뀌지 않는다고 가정하지 말라고 컴파일러에 알란다
유의할 점은
원자성 제공 X, 인터럽트 차단 X, CPU 메모리 펜스 X
라는 거다