12. Concurrency - Locks
- Haram Lee
- 2026-05-25
- studies / 26-1 / operating-systems
The Classic Example
- 대표적인 예시는 은행 계좌 출금 문제다.
- 어떤 계좌에 1,000,000원이 있다고 하자.
- 두 사람이 동시에 ATM에서 각각 100,000원을 출금하면, 최종 잔액은 800,000원이 되어야 한다.
- 하지만 아래 코드가 동시에 실행되면 문제가 생길 수 있다.
c
int withdraw(account, amount)
{
balance = get_balance(account);
balance = balance - amount;
put_balance(account, balance);
return balance;
}
The Real Example
- 실제 machine instruction 수준에서도 같은 문제가 생긴다.
- 예를 들어 전역 변수
g를 증가시키는 코드를 생각해보자.
c
extern int g;
void inc()
{
g++;
}
Sharing Resources
- local variable은 보통 thread 간 공유되지 않는다.
- local variable은 stack에 저장된다.
- 각 thread는 자기 stack을 가진다.
- 따라서 다른 thread의 stack에 있는 local variable pointer를 넘기거나 저장하면 위험하다.
- global variable은 thread 간 공유된다.
- static data segment에 저장된다.
- 같은 process 안의 모든 thread가 접근할 수 있다.
- dynamic object도 thread 간 공유될 수 있다.
- heap에 저장된다.
- pointer를 통해 여러 thread가 접근할 수 있다.
- process 사이에서도 shared memory를 사용하면 memory를 공유할 수 있다.
- 즉 concurrency 문제는 주로 다음에서 발생한다.
- global variable
- heap object
- shared memory
- kernel 내부 shared data structure
Synchronization Problem
- concurrency는 non-deterministic result를 만들 수 있다. thread scheduling이 programmer의 직접 통제 아래 있지 않기 때문이다. 두 개 이상의 thread가 shared resource에 접근하고, 그중 하나 이상이 write를 하면 race condition이 생길 수 있다.
- race condition(경쟁 상태)
- 이런 bug는 debugging하기 어렵다.
- 실행할 때마다 재현되지 않을 수 있다.
- print문을 넣으면 timing이 바뀌어서 bug가 사라질 수도 있다.
- 따라서 shared resource 접근을 제어하기 위한 synchronization mechanism이 필요하다.
- synchronization은 concurrency를 제한해서 correctness를 지키는 방식이다.
Concurrency in the Kernel

- concurrency는 user-level program에서만 생기는 것이 아니다.
- kernel 안에서도 concurrency가 생긴다.
- kernel에서 동시에 실행될 수 있는 흐름은 다음과 같다.
- system call handlers
- interrupt handlers
- background kernel threads
- 예를 들어 어떤 system call handler가 kernel data structure를 수정하는 중에 interrupt handler가 같은 data structure에 접근할 수 있다.
- 또는 multi-core 환경에서는 여러 CPU가 동시에 kernel code를 실행할 수 있다.
- 따라서 kernel 내부에서도 shared resource를 보호해야 한다.
- OS kernel에서 lock이 중요한 이유가 여기에 있다.
Critical Section
- critical section: shared resource에 접근하는 코드 영역
- 보통 shared variable이나 shared data structure를 읽거나 수정하는 코드가 critical section이 된다.
- critical section에는 mutual exclusion이 필요하다.
- mutual exclusion은 다음을 의미한다.
- 한 번에 하나의 thread만 critical section에 들어갈 수 있다.
- 다른 thread들은 critical section 입구에서 기다려야 한다.
- 한 thread가 critical section을 빠져나오면 다른 thread가 들어갈 수 있다.
- critical section은 atomic하게 실행되어야 한다.
- all-or-nothing처럼 보이도록 해야 한다.
- 중간 상태를 다른 thread가 관찰하면 안 된다.
Locks
- lock은 mutual exclusion을 제공하는 memory object다.
- lock은 보통 두 가지 operation을 가진다.
acquire()- lock이 free가 될 때까지 기다린다.
- lock이 free이면 lock을 잡는다.
release()- lock을 해제한다.
- 기다리고 있던 thread가 있으면 진행할 수 있게 한다.
- lock 사용 방식은 다음과 같다.
c
acquire(lock);
/* critical section */
release(lock);- lock은 처음에는 free 상태다.
acquire()는 caller가 lock을 얻을 때까지 return하지 않는다.- lock을 얻은 thread만 critical section에 들어간다.
- lock을 얻지 못한 thread는 기다린다.
- lock을 기다리는 방식은 크게 두 가지다.
- spin
- 계속 반복하면서 lock이 풀렸는지 확인한다.
- spinlock
- block
- CPU를 포기하고 잠든다.
- mutex
- spin
- lock의 핵심 보장은 다음이다.
- at most one thread can hold a lock at a time
Pthread Mutex
- Pthread에서는 mutex를 통해 lock을 사용할 수 있다.
- mutex는 mutual exclusion을 제공하는 대표적인 synchronization primitive다.
c
int pthread_mutex_init(
pthread_mutex_t *mutex,
const pthread_mutexattr_t *mattr
);
void pthread_mutex_destroy(
pthread_mutex_t *mutex
);
void pthread_mutex_lock(
pthread_mutex_t *mutex
);
void pthread_mutex_unlock(
pthread_mutex_t *mutex
);pthread_mutex_lock()은 lock을 얻을 때까지 기다린다.pthread_mutex_unlock()은 lock을 해제한다.- Pthread mutex는 사용자 프로그램에서 critical section을 보호할 때 사용한다.
Using Locks
- 은행 계좌 예제에 lock을 적용하면 다음처럼 된다.

c
int withdraw(account, amount)
{
acquire(lock);
balance = get_balance(account);
balance = balance - amount;
put_balance(account, balance);
release(lock);
return balance;
}Shared Linked List
c
typedef struct __node_t {
int key;
struct __node_t *next;
} node_t;
typedef struct __list_t {
node_t *head;
} list_t;
void List_Init(list_t *L) {
L->head = NULL;
}
void List_Insert(list_t *L, int key)
{
node_t *new =
malloc(sizeof(node_t));
assert(new);
new->key = key;
new->next = L->head;
L->head = new;
}
int List_Lookup(list_t *L, int key)
{
node_t *tmp = L->head;
while (tmp) {
if (tmp->key == key)
return 1;
tmp = tmp->next;
}
return 0;
}C
new->next = L->head; // L->head 읽기
L->head = new; // L->head 쓰기
Linked-List Race

- linked list race의 핵심은 다음이다.
- 두 thread가 같은 old head를 본다.
- 두 node 모두 old head를 next로 가진다.
- 하지만
L->head에는 둘 중 하나만 남는다.
- 결과적으로 하나의 insert가 사라진다.
- data structure의 invariant가 깨진다.
- linked list의 invariant는 보통 다음과 같다.
head는 list의 첫 node를 가리킨다.- 각 node의
next는 다음 node를 가리킨다.
- insert 중간에는 이 invariant가 잠깐 깨질 수 있다.
- 이 중간 상태를 다른 thread가 보면 문제가 생긴다.
- lock은 이런 중간 상태를 다른 thread가 보지 못하게 막는다.

Locking Linked Lists
- linked list를 보호하는 가장 단순한 방법은 list 전체에 lock 하나를 두는 것이다.
- 여기서 잡는 lock은
L->lock이고, 보호 대상은L->head와 list 구조 전체다. - lock variable 자체와 protected data를 구분해야 한다.
pthread_mutex_lock(&L->lock)은L->lock을 잡아서L->headupdate를 atomic하게 보이게 만드는 것이다.
c
typedef struct __list_t {
node_t *head;
pthread_mutex_t lock;
} list_t;- insert는 다음처럼 감쌀 수 있다.
c
void List_Insert(list_t *L, int key)
{
node_t *new = malloc(sizeof(node_t));
assert(new);
new->key = key;
pthread_mutex_lock(&L->lock);
new->next = L->head;
L->head = new;
pthread_mutex_unlock(&L->lock);
}- 중요한 것은 lock을 어디부터 어디까지 잡을지 결정하는 것이다.
- 너무 크게 잡으면 안전하지만 parallelism이 줄어든다.
- 너무 작게 잡으면 race condition이 남을 수 있다.
Locking Approach 1: Everything as Critical Section
- 첫 번째 방식은 함수 전체를 critical section으로 보는 것이다.
- 예를 들어
List_Insert()전체를 lock으로 감싼다.- 장점은 단순하고 안전하다.
- 단점은 필요 이상으로 concurrency를 제한한다.
- 예를 들어
malloc()이나assert()까지 lock 안에 넣으면, 실제 shared list를 건드리지 않는 부분까지 serialized된다. - 따라서 performance가 나빠질 수 있다.
Locking Approach 2: Critical Section as Small as Possible
- 두 번째 방식은 critical section을 최대한 작게 잡는 것이다.
- insert에서 실제 shared structure를 건드리는 부분은 다음이다.
new->next = L->headL->head = new
- insert에서 실제 shared structure를 건드리는 부분은 다음이다.
- 따라서 이 부분만 lock으로 감쌀 수 있다.
- 장점은 lock hold time이 짧다.
- 단점은 어디까지가 shared invariant를 깨뜨리는 부분인지 정확히 알아야 한다.
- 잘못 줄이면 bug가 생긴다.
- 즉 critical section을 줄이는 것은 performance에는 좋지만 correctness 판단이 더 어려워진다.
Locking Approach 3: Lookup은?
- insert뿐 아니라 lookup도 생각해야 한다.
- lookup은 list를 읽기만 한다.
c
int List_Lookup(list_t *L, int key)
{
node_t *tmp = L->head;
while (tmp) {
if (tmp->key == key)
return 1;
tmp = tmp->next;
}
return 0;
}- 만약 list에 insert만 있고 delete가 없다면 lookup에는 lock이 없어도 안전할 수 있다.
- 이유는 node가 사라지지 않기 때문이다.
- 하지만 delete가 있다면 lookup 중인 node가 다른 thread에 의해 free될 수 있다.
- 그러면 lookup도 lock이 필요하다.
- 즉 lock이 필요한지는 단순히 read냐 write냐만으로 결정되지 않는다.
- data structure가 어떤 operation을 지원하는지 함께 봐야 한다.
Requirements for Locks
- 좋은 lock은 세 가지 측면에서 평가할 수 있다.
- correctness
- mutual exclusion
- 한 번에 하나의 thread만 critical section에 들어가야 한다.
- progress 또는 deadlock-free
- 여러 thread가 critical section에 들어가고 싶어 하면, 그중 하나는 진행할 수 있어야 한다.
- bounded waiting 또는 starvation-free
- 기다리는 thread가 언젠가는 lock을 얻을 수 있어야 한다.
- mutual exclusion
- fairness
- 각 thread가 lock을 얻을 공정한 기회를 가져야 한다.
- performance
- contention이 없을 때 lock overhead가 작아야 한다.
- contention이 있을 때도 너무 많은 CPU cycle을 낭비하면 안 된다.
- multi-core 환경에서도 성능이 잘 나와야 한다.
Implementing Locks
- lock 구현 방식은 환경에 따라 달라진다.
- 크게 두 경우를 생각할 수 있다.
- single-core
- multi-core
- single-core에서는 interrupt를 막는 것만으로도 critical section을 보호할 수 있다.
- 하지만 multi-core에서는 한 CPU의 interrupt를 꺼도 다른 CPU가 동시에 접근할 수 있다.
- 따라서 multi-core에서는 atomic instruction이 필요하다.
- lock 구현 방식은 크게 다음과 같다.
- controlling interrupts
- software-only algorithm
- hardware atomic instruction
Controlling Interrupts
- single-core 시스템에서는 critical section 동안 interrupt를 disable할 수 있다.
c
void acquire(struct lock *l)
{
cli();
}
void release(struct lock *l)
{
sti();
}cli()는 interrupt를 비활성화한다.sti()는 interrupt를 활성화한다.- interrupt를 끄면 timer interrupt 같은 외부 event에 의해 context switch가 발생하지 않는다.
- 따라서 single-core에서는 critical section이 중간에 끊기지 않는다.
- 장점은 단순하다는 것이다.
- single-processor kernel에서는 유용할 수 있다.
- 하지만 단점이 크다.
- user program에게 제공할 수 없다.
- multi-processor 시스템에서는 충분하지 않다.
- critical section이 길면 중요한 interrupt가 지연되거나 손실될 수 있다.
- 현대 CPU에서는 atomic instruction보다 느릴 수 있다.
- 특히 multi-core에서는 한 core의 interrupt를 꺼도 다른 core는 계속 실행된다.
- 그래서 multi-core에서는 atomic instruction 기반 lock이 필요하다.
Multicore: Initial Attempt
- multi-core에서 단순히 flag 하나로 spinlock을 만들면 다음처럼 생각할 수 있다.
c
struct lock {
int held = 0;
};
void acquire(struct lock *l)
{
while (l->held);
l->held = 1;
}
void release(struct lock *l)
{
l->held = 0;
}- 이 코드는 직관적으로는 맞아 보인다.
held가 1이면 기다리고, 0이면 1로 바꿔 lock을 잡는 방식이다.- 하지만 multi-core에서는 틀릴 수 있다.
- 이유는 다음 두 동작이 atomic하지 않기 때문이다.
l->held가 0인지 확인l->held = 1로 설정
- 두 CPU가 동시에
held == 0을 보고 둘 다held = 1을 실행할 수 있다. - 그러면 두 thread가 동시에 lock을 잡았다고 생각한다.
- mutual exclusion이 깨진다.
- 따라서 lock 구현에는 read와 write를 하나의 atomic operation으로 묶는 기능이 필요하다.
Software-only Algorithms
- hardware atomic instruction 없이 lock을 구현하려는 algorithm들도 있다.
- 대표적으로 다음이 있다.
- Dekker’s algorithm
- Peterson’s algorithm
- Lamport’s Bakery algorithm
- 이런 알고리즘들은 이론적으로 mutual exclusion을 제공할 수 있다.
- 하지만 실제 OS나 modern multicore 환경에서는 보통 hardware atomic instruction을 사용한다.
- 이유는 구현과 성능, memory ordering 문제가 복잡하기 때문이다.
Hardware Atomic Instructions
- 현대 CPU는 lock 구현을 위해 atomic instruction을 제공한다.
- atomic instruction은 read-modify-write를 indivisible하게 수행한다.
- 대표적인 atomic instruction은 다음과 같다.
- Test-And-Set
- Compare-And-Swap
- Load-Linked / Store-Conditional
- Fetch-And-Add
- 이런 instruction은 lock 구현의 핵심 building block이다.
- OS의 spinlock도 보통 이런 atomic instruction을 기반으로 구현된다.
Test-And-Set
- Test-And-Set은 memory location의 old value를 읽으면서 동시에 new value로 바꾸는 atomic instruction이다.
- x86에서는
xchg가 비슷한 역할을 한다. - 개념적으로는 다음과 같다.
c
int TestAndSet(int *v, int new)
{
int old = *v;
*v = new;
return old;
}- 중요한 점은 이 전체 동작이 atomic하게 실행된다는 것이다.
- 즉 두 CPU가 동시에 실행해도 중간에 끼어들 수 없다.
- Test-And-Set을 사용하면 spinlock을 만들 수 있다.
c
struct lock {
int held;
};
void acquire(struct lock *l)
{
while (TestAndSet(&l->held, 1));
}
void release(struct lock *l)
{
l->held = 0;
}- lock이 free이면
held는 0이다. TestAndSet(&held, 1)은 old value 0을 반환하고, 동시에 held를 1로 만든다.- old value가 0이면 lock 획득에 성공한 것이다.
- old value가 1이면 이미 누가 lock을 잡고 있으므로 계속 spin한다.
Compare-And-Swap
- Compare-And-Swap, CAS는 old value가 expected value와 같을 때만 new value로 바꾸는 atomic instruction이다.
- x86에서는
cmpxchg가 이에 해당한다. - 개념적으로는 다음과 같다.
c
int CompareAndSwap(int *v, int expected, int new)
{
int old = *v;
if (old == expected)
*v = new;
return old;
}- CAS를 사용한 lock은 다음처럼 만들 수 있다.
c
void acquire(struct lock *l)
{
while (CompareAndSwap(&l->held, 0, 1));
}held가 0일 때만 1로 바꾸려고 시도한다.- old value가 0이면 lock을 얻는다.
- old value가 1이면 누군가 lock을 가지고 있으므로 계속 기다린다.
- CAS는 lock뿐 아니라 lock-free data structure에서도 많이 쓰인다.
Load-Linked and Store-Conditional
- LL/SC는 MIPS, Alpha, PowerPC, ARM 등에서 지원되는 방식이다.
- Load-Linked는 memory에서 값을 읽는다.
- Store-Conditional은 그 사이에 다른 store가 없었을 때만 write에 성공한다.
- 만약 다른 thread나 CPU가 같은 address에 write했다면 Store-Conditional은 실패한다.
- 개념적으로 lock은 다음처럼 만들 수 있다.
c
void acquire(struct lock *l)
{
while (1) {
while (LL(&l->held));
if (SC(&l->held, 1))
return;
}
}
void release(struct lock *l)
{
l->held = 0;
}- LL로 lock 상태를 읽고, 비어 있으면 SC로 1을 쓰려고 한다.
- SC가 성공하면 lock을 얻은 것이다.
- 실패하면 누군가 중간에 건드린 것이므로 다시 시도한다.
Basic Spinlocks Are Unfair
- 단순 spinlock은 fairness를 보장하지 않는다.
- lock이 풀렸을 때 어떤 thread가 lock을 얻을지는 scheduler와 timing에 달려 있다.
- 어떤 thread는 계속 lock을 얻고, 어떤 thread는 계속 밀릴 수 있다.
- 즉 starvation이 가능하다.
- 특히 scheduler는 lock의 내부 상태를 모른다.
- scheduler는 어떤 thread가 lock을 기다리는지, 누가 lock holder인지 모른 채 CPU를 배정할 수 있다.
- 그래서 lock을 기다리는 thread를 계속 실행시키고, 정작 lock을 들고 있는 thread는 실행하지 않는 비효율이 생길 수 있다.
Fetch-And-Add
- Fetch-And-Add는 memory 값을 atomic하게 증가시키면서 old value를 반환하는 instruction이다.
- x86에서는
xadd가 이에 해당한다. - 개념적으로는 다음과 같다.
c
int FetchAndAdd(int *v, int a)
{
int old = *v;
*v = old + a;
return old;
}- Fetch-And-Add는 ticket lock 구현에 사용할 수 있다.
- ticket lock은 lock을 얻으려는 thread에게 번호표를 나눠주고, 자기 차례가 올 때까지 기다리게 한다.
Ticket Locks
- ticket lock은 fairness를 개선한 lock이다.
- 구조는 다음과 같다.
c
struct lock {
int ticket;
int turn;
};ticket은 다음으로 나눠줄 번호다.turn은 현재 lock을 얻을 차례인 번호다.- acquire는 다음처럼 동작한다.
c
void acquire(struct lock *l)
{
int myturn = FetchAndAdd(&l->ticket, 1);
while (l->turn != myturn);
}- 각 thread는
FetchAndAdd()로 자기 ticket number를 받는다. - 그리고
turn이 자기 번호가 될 때까지 기다린다. - release는 다음처럼 동작한다.
c
void release(struct lock *l)
{
l->turn = l->turn + 1;
}- lock을 놓을 때
turn을 증가시켜 다음 ticket을 가진 thread가 들어오게 한다. - ticket lock의 장점은 bounded waiting을 제공한다는 것이다.
- 즉 먼저 온 thread가 먼저 lock을 얻는다.
- starvation을 줄일 수 있다.
CPU Scheduler is Ignorant

- lock과 scheduler는 독립적으로 동작한다.
- scheduler는 누가 lock을 들고 있는지, 누가 lock을 기다리는지 알지 못한다.
- 그래서 다음과 같은 비효율이 생길 수 있다.
- A가 lock을 들고 있다.
- B가 A가 가진 lock을 기다리고 있다.
- 그런데 scheduler가 A 대신 B를 계속 실행한다.
- 이 경우 B는 계속 spin만 한다. A가 실행되어야 lock을 풀 수 있는데, A가 CPU를 못 받으면 system 전체가 비효율적으로 실행된다.
- 즉 spinlock은 lock holder가 빨리 실행되어 critical section을 끝낸다는 가정에 의존한다.
- 하지만 scheduler가 그 사실을 모르면 CPU cycle이 낭비된다.
Ticket Locks with yield
- ticket lock에서 계속 spin하는 대신
yield()를 호출하게 만들 수도 있다.
c
void acquire(struct lock *l)
{
int myturn = FetchAndAdd(&l->ticket, 1);
while (l->turn != myturn)
yield();
}
void release(struct lock *l)
{
l->turn = l->turn + 1;
}yield()는 CPU를 다른 thread에게 양보한다. 이렇게 하면 기다리는 thread가 CPU를 계속 낭비하지 않는다. 특히 critical section이 길거나 lock holder가 CPU를 받아야 하는 상황에서는 도움이 된다.- 하지만
yield()에도 context switch 비용이 있다. 따라서 아주 짧은 critical section에서는 spin이 더 나을 수 있다. 반대로 critical section이 길면 yield나 blocking 방식이 더 낫다.
Yield Instead of Spin

- spin은 기다리는 동안 CPU를 계속 사용한다.
- yield는 기다리는 동안 CPU를 양보한다.
- 두 방식의 차이는 다음과 같다.
| 방식 | 동작 | 장점 | 단점 |
|---|---|---|---|
| spin | lock이 풀릴 때까지 반복 확인 | critical section이 매우 짧으면 빠름 | CPU cycle 낭비 |
| yield | lock이 안 풀렸으면 CPU 양보 | lock holder가 실행될 기회를 줌 | context switch overhead |
| block | 잠들고 나중에 깨움 | 긴 대기에서 효율적 | sleep/wakeup 관리 비용 |
- lock 구현에서는 critical section의 길이와 contention 정도를 고려해야 한다.
- 짧고 자주 잡는 lock은 spinlock이 적합할 수 있다.
- 오래 기다릴 수 있는 lock은 mutex나 sleep lock이 더 적합할 수 있다.