멀티 프로세스/멀티 스레드 환경에서는 동시에 공유 자원에 접근할 경우 데이터의 일관성(consistency)이 깨질 수 있습니다. 이러한 구간을 임계영역(critical section)이라 하며, 여러 스레드가 동시에 진입하지 못하도록 제어해야 합니다.
이때 사용하는 동작이 동기화(synchronization)이며, 대표적으로 락(lock)을 활용하여 한 번에 하나의 스레드만 진입하도록 보장합니다.
본 글에서는 자바(Java 17 기준)에서 자주 사용되는 세 가지 락 기법, 스핀락(spinlock), 뮤텍스(mutex), 세마포어(semaphore)를 소개합니다.
스핀락은 임계영역에 동시에 접근했을 때 while 루프를 돌며 락이 풀릴 때까지 바쁘게 대기(busy-wait)하는 방식입니다. 그렇기에 while 루프를 돌리는 과정에서 CPU 낭비가 심할 수 있습니다.
아래 수도코드를 보시면 처음으로 임계영역에 도착한 스레드는 lock을 얻고 그다음으로 도착하는 스레드는 while 루프에서 대기하는 것을 확인할 수 있습니다.
class Executor {
void run() {
while (락이 반환 될 때까지 확인 하고 락 취득);
... critical section;
락 반환;
}
}
여기서 주의해야 할 점은 락 상태를 나타내는 변수 자체도 공유자원이므로, CAS(Compare-And-Set) 같은 원자적 연산을 통해 값을 변경해 줄 필요가 있습니다.
위에서 말했듯이 현재 lock이 된 상태를 표시하는 locked 변수도 공유자원이므로, AtomicBoolean을 활용하여 원자적 연산을 통해 일관성을 보장해 주었습니다.
public class SpinLock {
private final AtomicBoolean locked = new AtomicBoolean(false);
public void lock() {
while (!locked.compareAndSet(false, true)) {
Thread.onSpinWait(); // Java 9부터 추가된 JVM이 CPU에게 하드웨어적 최적화 힌트를 주는 메소드
}
}
public void unlock() {
locked.set(false);
}
}
public class Executor {
private final ExecutorService threadPool = Executors.newFixedThreadPool(3); // 스레드 풀 크기 = 3
private final SpinLock spinLock = new SpinLock();
public void run() throws InterruptedException {
for (int i = 0; i < CRITICAL_SECTION_EXECUTIONS; i++) {
threadPool.execute(() -> {
spinLock.lock();
try {
criticalSection.run();
} finally {
spinLock.unlock();
}
});
}
}
}
뮤텍스는 한 번에 하나의 스레드만 임계영역에 진입할 수 있도록 하는 락입니다. 스핀락과 달리 락을 얻지 못한 스레드는 대기 큐에 들어가 블록 상태로 전환 되며, 락이 해제될 때 OS 스케줄러에 의해 다시 깨워집니다. 또한 이 과정에서 context switching이 발생합니다.
아래 수도코드를 보시면 락이 걸려있다면 현재 스레드를 큐에 담고 unlock 시 다음 스레드를 깨우는 방식으로 동작하는 것을 확인할 수 있습니다.
class Executor {
void run() {
lock();
... critical section;
unlock();
}
void lock() {
if (락이 걸려있다면) {
... 현재 스레드를 큐에 넣음;
} else {
... 락 취득;
}
}
void unlock() {
if (큐에 스레드가 존재하면) {
... 다음 스레드를 깨움;
} else {
... 락 반환;
}
}
}
Java에서는 ReentrantLock을 통해 내부적으로 뮤텍스를 지원합니다.
public class Executor {
private final ExecutorService threadPool = Executors.newFixedThreadPool(3);
private final Lock mutex = new ReentrantLock(); // Java 내부적으로 구현해 놓은 뮤텍스
public void run() throws InterruptedException {
for (int i = 0; i < CRITICAL_SECTION_EXECUTIONS; i++) {
threadPool.execute(() -> {
mutex.lock();
try {
criticalSection.run();
} finally {
mutex.unlock();
}
});
}
}
}
뮤텍스는 while 루프를 돌며 CPU 낭비를 하지 않기 때문에 대부분의 일반적인 상황에서 스핀락 보다 뮤텍스가 더 효율적입니다.
하지만 만약 멀티코어 환경에서 임계영역의 작업 실행 시간이 context switching 시간보다 작은 경우 뮤텍스 보다 스핀락이 더 효율적일 수 있습니다.
세마포어는 하나 이상의 프로세스/스레드가 임계영역에 접근할 수 있도록 허용하는 동기화 도구입니다. 기존의 스핀락과 뮤텍스와 같이 하나의 프로세스/스레드만 접근 가능하게 하는 방식은 이진 세마포어(binary semaphore)라고 하며, 그 외에는 카운팅 세마포어(Counting Semaphore)라고 합니다.
아래 수도코드를 보시면 락 취득과 반환이 아닌 permit 수를 변경하는 것을 확인할 수 있습니다. 또한 다수의 스레드가 접근할 수 있기 때문에 메소드 이름도 lock(), unlock()이 아닌 acquire(), release()인 것을 확인할 수 있습니다.
class Executor {
void run() {
acquire(); // permit 얻기
... critical section;
release();
}
void acquire() {
if (남은 permit이 없다면) {
... 현재 스레드를 큐에 넣음;
} else {
permit--;
}
}
void release() {
if (큐에 스레드가 존재하면) {
... 다음 스레드를 깨움;
} else {
permit++;
}
}
}
Java에서는 뮤텍스와 마찬가지로 세마포어를 지원합니다.
public class Executor {
private final ExecutorService threadPool = Executors.newFixedThreadPool(3);
private static final Semaphore semaphore = new Semaphore(1); // permits=1로 하여 스핀락, 뮤텍스와 같은 동작을 하는 이진 세마포어 생성
public void run() throws InterruptedException {
for (int i = 0; i < CRITICAL_SECTION_EXECUTIONS; i++) {
threadPool.execute(() -> {
semaphore.acquire();
try {
criticalSection.run();
} finally {
semaphore.release();
}
});
}
}
}
언뜻 보기에는 뮤텍스와 이진 세마포어는 똑같이 동작하는 것으로 보이지만, 중요한 두가지 차이가 있습니다.
따라서 단순히 상호배제만 필요하다면 뮤텍스를, 자원 슬롯 제한이나 실행 순서 제어가 필요하다면 세마포어를 사용하는 것이 적절합니다.