본문으로 건너뛰기

GorkkingConcurrency

Chapter 8: 동시성과 관련된 문제 해결하기: 경쟁 조건과 동기화

  • 이번 장에서 배울 내용
    • 동시성과 관련해 가장 흔하게 발생하는 문제인 경쟁 조건을 식별하고 해결하는 방법을 배운다
    • 원시적인 동기화 수단을 사용해 작업 간에 자원을 안전하고 신뢰성 있게 공유하는 방법을 배운다

8.1 공유 자원

  • 운영체제가 여러 작업을 동시에 실행해주지만, 이들 작업도 한정된 자원에 의존한다.
  • 작업들은 서로의 존재를 모른 채 독립적으로 진행되지만, 공유 자원을 사용하려다 충돌이 발생할 수 있다.
  • 서로 실행되는 순서나 실행 환경에 상관없이 여러 작업이 접근해 사용해도 의도대로 동작하는 함수나 연산의 성질을 스레드 안전(thread-safe)이라고 한다.
  • 스레드 안전성을 더 잘 이해할 수 있도록 스레드 안전하지 않은 경우의 예를 먼저 살펴보자.

8.2 경쟁 조건

  • 여러분이 은행 업무 애플리케이션을 개발한다고 가정해보자.
  • 이 애플리케이션에는 계좌를 나타내는 객체가 있다.
  • 다른 작업들이 이들 계좌에 잔고를 입금하거나 인출할 수 있다.
  • 은행의 ATM은 공유 메모리 방식으로 만들어져 모든 ATM이 같은 계좌 객체에 읽고 쓰기를 하면 어떻게 될까?

예: 잔고에 10을 더하는 입금 함수

  • 입금은 보통 “잔고를 읽고 → 더하고 → 다시 쓴다”는 읽기–수정–쓰기 세 단계로 이루어진다.
  • balance = balance + 10 한 줄도 실제로는 읽기, 덧셈, 쓰기로 나뉜다.

Java 예시

class Account {
private int balance = 100;

// 동기화 없음 → 여러 스레드가 동시에 호출하면 경쟁 조건
void deposit(int amount) {
balance = balance + amount; // ① 읽기 ② 덧셈 ③ 쓰기
}

int getBalance() {
return balance;
}
}

// 두 스레드가 같은 계좌에 각각 deposit(10) 호출
Account account = new Account();
Thread t1 = new Thread(() -> account.deposit(10));
Thread t2 = new Thread(() -> account.deposit(10));
t1.start();
t2.start();
t1.join();
t2.join();
// 기대: 100 + 10 + 10 = 120
// 실제: 110이 나올 수 있음 (한 번의 +10이 손실)
  • 인터리빙이 없을 때 (순차 실행)
    • 스레드 1: 잔고 100 읽음 → 110 계산 → 110 씀
    • 스레드 2: 잔고 110 읽음 → 120 계산 → 120 씀
    • 최종 잔고: 120 (의도한 결과)
  • 인터리빙이 있을 때 (경쟁 조건)
    • 스레드 1: 잔고 100 읽음
    • 스레드 2: 잔고 100 읽음 (1이 쓰기 전에 읽음)
    • 스레드 1: 110 계산 → 110 씀
    • 스레드 2: 110 계산 → 110 씀 (1이 쓴 110을 덮어씀)
    • 최종 잔고: 110 (한 번의 “+10”이 사라진 결과)
  • 두 스레드가 “읽기–수정–쓰기”를 겹치면서 서로의 쓰기를 덮어쓰면 갱신 손실이 발생한다.
  • 이것이 경쟁 조건의 전형적인 예이다.

경재 조건이 발생하는 이유

  • 경쟁 조건이 발생하는 요인은 여러 가지가 있다.
  • 컴파일러는 성능 향상을 위해 프로그램의 의미를 바꾸지 않는 범위에서 내에서 다양한 최적화를 시도한다.
  • 만약 컴파일러를 이런 교차 실행이나 최적화를 하지 못하도록 하면, 성능이 떨어질 수 있다.

하이젠버그

  • 경쟁 조건으로 발생하는 오류는 재현하기도 찾아내기도 어렵다.
  • 제멋대로 나타났다가 사라지는 하이젠버그다.
  • 경쟁 조건은 의미적 버그이기 때문에 프로그램을 실행해야 발견할 수 있고 단순히 코드를 읽어서는 발견할 수 없다.

8.3 동기화

  • 동기화는 여러 작업 간에 공유 자원에 대한 접근을 제어하는 수단이다.
  • 동기화를 적절히 사용하면 여러 작업이 상호 배제와 정확한 순서로 공유자원에 접근하는 것이 보장된다.
  • 동기화는 코드의 임계 구역을 보호할 수 있는 수단이 된다.
  • 임계 구역: 코드에서 공유 자원을 접근하면서 여러 작업에서 함께 실행될 가능성이 있는 부분
    • 예를 들어, 프린터처럼 동시에 하나의 클라이언트밖에 사용할 수 없는 공유 자원을 사용하는 코드는 임계 구역이 된다.

Lock

  • 프로세서는 동기화를 구현할는 데 쓸 수 있는 인스트력션이 있다.
  • 이는 코드의 특정 구간 안에서 한시적으로 인터럽트를 무효화하는 기능을 한다.
  • 임계 구역을 인터럽트에 방해받지 않고 실행할 수 있게 한다.
  • 이 인스트럭션은 개발자들이 많이 사용하며 다양한 프로그래밍 언어에서 라이브러리 함수 형태로 추상화돼 제공된다.
  • 따라서 일반 개발자들도 언어별로 구현된 이들 함수를 사용하여 프로세서의 기계어 인스트럭션까지 건드리는 일 없이 자신의 코드 속 임계 구역을 보호할 수 있다.
  • 임계 구역을 보호하는 수단 중 가장 기본적인 것으로 Lock이 있다.
    • Lock은 기능과 동작에 따라 여러 가지 종류가 있다.

상호 배제, 뮤텍스

  • 락은 간단히 비유하면 작업이 자신이 사용중인 자원에 "방해하지 마시오" 표지판을 걸어놓는 것과 같다.
  • 이 표지판은 자원을 사용하기 시작할 때 걸고 사용이 끝나면 제거하는 것이다.
  • 모든 작업은 먼저 자원에 표지판이 있는지 확인하고 없으면 자원을 사용한다.
  • 만약 "방해하지 마시오" 표지판이 있으면 해당 자원에 표지판이 제거될 때까지 대기 상태로 기다린 다음 자원을 사용한다.
  • 이러한 방식을 상호 배제, 또는 뮤텍스라고 한다.
  • 이렇게 하면 공유 자원에 동시에 접근할 수 있는 작업이 항상 하나뿐이기 때문에 이런 이름이 붙었다.
  • 뮤텍스
    • 뮤텍스에는 잠김과 해제 두 가지 상태가 있다.
    • 처음 생성하면 해제 상태다.
    • acquire()와 release() 두 가지 메소드를 제공한다.
    • acquire()는 해당 뮤텍스의 상태를 잠김으로 바꾸며 이후 release()를 호출할 때까지 다른 작업이 해당 뮤텍스를 사용할 수 없게 한다.
    • release()는 해당 뮤텍스의 상태를 해제 상태로 바꾸며 이후 다른 작업이 해당 뮤텍스를 사용할 수 있게 한다.

세마포어

  • 세어포어 역시 뮤텍스와 비슷하게 공유 자원의 접근을 제어하기 위한 동기화 수단이다.
  • 세마포어는 공유 자우너에 하나 이상의 작업이 접근할 수 있다는 점에서 뮤텍스와 다르다.
  • 다시 말해, 두개 이상의 작업이 세모포어에 락을 걸거나 해제할 수 있다.
  • 세마포어 내부적으로 현재 락을 걸 수 있는, 남은 횟수를 세는 계수기가 들어 있다.
  • 계수기의 값이 양수라면 다른 작업이 락을 걸 수 있으며, 락을 걸고 나면 횟수를 하나 줄인다.
  • 계수기의 값이 0에 도달한 후 락을 걸려고 하는 작업은 대기 상태로 들어가 대기한다.
  • 다른 작업이 공유 자원의 사용을 마치면 세마포어의 락을 해제하고 계수기의 값이 증한다. 이때 대기중인 다른 스레드가 있으면 해당 스레드를 재개한다.
  • 뮤텍스는 세마포어의 특수한 형태라고 할 수 있다.
    • 뮤텍스의 계수기는 0과 1 두 가지 값만 가질 수 있으므로 이진 세마포어에 해당한다.

원자적 연산

  • 원자적 연산은 원시 데이터 타입을 다루는 가장 단순한 형태의 동기화 기법이다.
  • 원자적이라는 말은 해당 연산의 중간 단계를 다른 스레드가 엿볼 수 없다는 뜻이다.
  • 이러한 연산을 활용하려먼 특수한 기계어 인스트럭션과 하드웨어 수준의 원자적 성질을 소프트웨어 수준까지 끌어오는 하드웨어와 소프트웨어 간의 긴밀한 협조가 필요하다.
  • 대부분의 프로그래밍 언어에는 원자적 연산이 가능한 데이터 구조가 있다.
  • 자바에는 AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference 등의 클래스가 있다.

chapter 9: 동시성과 관련된 문제 해결하기: 교착 상태와 기아 상태

  • 이번 장에서 배울 내용
    • 동시성과 관련해 흔하게 발생하는 문제인 교착 상태(데드락, 라이브락)와 기아 상태를 식별하고 해결하는 방법을 배운다
    • 널리 쓰이는 동시성 디자인 패턴인 프로듀서-컨슈머 패턴, 리더-라이터 패턴을 익힌다.

9.1 철학자들의 만찬 문제

  • 철학자 다섯 명이 원탁에 앉아 만두 한 접시를 나눠 먹는다고 가정해보자.
  • 철학자와 철학자 사이에는 젓가락이 한 개만 놓여 있다.
  • 젓가락은 동시에 한명만 사용할 수 있으므로 젓가락을 다른 사람이 사용하고 있으면 다 쓸 때까지 기다려야 한다.
  • 젓가락을 든 사람이 만두를 먹고 나면 다른 사림이 사용할 수 있게 젓가락 두개를 모두 내려놓는다.
  • 철학자는 자신의 양옆에 있는 젓가락만 사용할 수 있다.
  • 문제는 철학자들이 서로 식사 중인지 사색 중인지 알 수 없는 상태에서 모든 철학자가 돌아가면서 만두를 먹고 사색할 수 있는 절차(알고리즘)를 설계하는 것이다.

문제가 있는 알고리즘

  • 철학자는 먼저 자신의 왼쪽에 놓인 젓가락을 사용할 수 있다면 집어 들고 락을 건다.
  • 그리고 자신의 오른쪽에 놓인 젓가락도 사용할 수 있다면 집어 들고 락을 건다.
  • 이제 젓가락 두개를 집었으니 임계 구역에 진입했다.
  • 만두를 먹기 시작한다.
  • 그리고 오른쪽 젓가락을 내려놓고 락을 해제한다.
  • 이이서 자신의 왼쪽 젓가락을 내려놓고 락을 해제한다.
  • 먹기를 마쳤으니, 사색을 시작한다.

9.2 데드락

  • 편의상 위 문제가 있는 알고리즘을 그대로 두되 철학자를 두 명으로 줄여보자.
  • 만두가 아직 남아있는데 프로그램이 멈춘 채 끝나지 않는다. 어떻게 된 일일까?
  • 첫 번째 철학자가 젓가락 A를 집었다. 동시에 두 번째 철학자가 젓가락 B를 집었다.
  • 각자 필요한 두개의 젓가락 중 하나를 확보했지만, 상대편이 다른 하나를 내려놓을 때까지 기다리는 상태다.
  • 이러한 상황을 데드락(Deadlock)이라고 한다.
  • 데드락이 발생하면 이미 다른 작업이 차지한 자원을 기다리는 상태가 되어 실행이 멈춘다.
  • 프로그램이 영원히 멈추며 직접 강제로 종료할 수 밖에 없다.
  • 운이 좋아 데드락에 빠지지 않고 애플리케이션이 동작할 수도 있다.
  • 동시성 프로그램에서 코드의 임계 구역에 상호 배체 수단을 사용하는 주된 이유가 바로 데드락을 방지하기 위해서다.

해결책 1: 중재인 모델

  • 데드락을 방지하려면 철학자들이 젓가락 두 개를 모두 갖든가 하나도 갖지 않아야 한다.
  • 이렇게 할 수 있는 가장 쉬운 방법은 철학자들이 젓가락을 집기 전에 중재인에게 요청하는 것이다.
  • 중재인은 젓가락을 담당하는 사람이다.
  • 철학자는 젓가락을 집기 전에 중재인에게 요청하고 중재인은 젓가락을 집을 수 있는지 확인한다.
  • 중재인은 한 철학자에게만 젓가락 두 개를 모두 내어준다.
  • 그 대신 젓가락을 내려놓을 때는 허락이 필요없다.

해결책 2: 자원의 우선순위

  • 락에 우선순위를 두어 철학자들이 항상 같은 젓가락부터 먼저 집게 하는 방법이다.
  • 먼저 철학자들끼리 두 젓가락 중에 어떤 것을 먼저 집을지 정하고 우선순위가 가장 높은 젓가락을 두고 경쟁한다.
  • 이긴 철학자가 우선순위가 높은 젓가락을 가져가고 우선순위가 낮은 젓가락은 테이블 위에 남아있다.
  • 이제 우선순위가 낮은 젓가락도 집고 만두를 먹기 시작한다.

해결책 3: 제한 시간

  • 데드락을 방지하는 또 다른 방법은 대기 상태에 제한 시간을 두는 방법이다.
  • 작업이 제한 시간 내에 모든 락을 획득하지 못하면 작업이 가진 모든 락을 해제하는 것이다.
  • 하지만 이 방법은 새로운 문제인 라이브락을 발생시킬 수 있다.

9.3 라이브락

  • 라이브락도 데드락과 비슷하게 같은 자원을 두고 두 작업이 경쟁할 때 발생하는 현상이다.
  • 다만 두 번째 락을 얻으려 할 때 첫 번째 락이 해제되면서 두 번째 락을 갖고 다시 첫 번쨰 락을 얻기 위해 대기하는 현상이라는 점이 데드락과는 다르다.
  • 철학자들이 서로 좀 더 예의를 갖춰 젓가락 두개를 모두 얻지 못하면 가진 젓가락을 내려 놓게 됐다고 상상해보자.
  • 라이브락은 기아 상태라고 하는 더 넓은 범주 문제의 한 부류이다.

9.4 기아 상태

  • 기아 상태는 스레드가 필요한 자원을 아예 얻지 못해 일하지 못하는 상태다.
  • 욕심 많은 다른 작업이 공유 자원을 독차지하고 있으면 기아 상태에 빠진 작업은 실행 될 기회조차 얻지 못한다.
  • 기아 상태는 스케줄링 알고리즘이 지나치게 단순할 때 발생한다.
  • 이 알고리즘은 런타임 시스템(운영체제)에 포함된다.
  • 운영체제마다 다르지만, 대게 우선순위가 높은 작업일수록 자원을 자주 배정받고, 우선순위가 낮은 작업은 자원을 적게 배정받는다.
  • 기아 상태를 방지하기 위해서는 대기 시간의 길이를 우선순위 계산에 산입하는 에이징 기법이 적용된 스케줄링 알고리즘을 사용해야 한다.
  • 모든 스레드가 시간이 지남에 따라 우선순위가 충분히 올라가면 자원이나 프로세서를 배정받고 실행을 완료하게 된다.

9.5 동기화 설계하기

  • 시스템을 설계할 때 당면한 문제를 잘 알려진 다른 문제와 비교하는 것이 도움이 된다.
  • 프로듀서-컨슈머 문제와 리더-라이터 문제가 유명하다.
  • 세마포어와 뮤텍스를 사용해 이들 문제를 효율적으로 해결할 수 있다.

프로듀서-컨슈머 문제

  • 끊임없이 물건을 만들어 버퍼에 넣는 생산자와 반대편에서 같은 버퍼에서 물건을 꺼내 하나씩 처리하는 소비자가 있다고 상상해보자.
  • 생산자는 자신의 속도에 맞춰 버퍼에 물건을 넣는다.
  • 소비자도 자신의 속도에 맞춰 버퍼에서 물건을 꺼내 처리하지만, 버퍼가 빈 상태에선 동작하지 않는다.
  • 이 예외를 적용하면 생산자는 버퍼가 꽉 찬 상태라면 더 이상 물건을 넣지 못하고 소비자는 버퍼가 빈 상태라면 더 이상 데이터를 읽어서는 안 된다.
  • 위 문제를 프로듀서-컨슈머 문제라고 한다.
  • 프로듀서-컨슈머 패턴에는 세 곳의 동기화 지점이 있다.
  • full
    • 이 세마포어는 프로듀서 스레드가 사용할 수 있는 공간이 얼마나 남아있는지 추척한다.
    • 프로그램 초기에는 버퍼가 비어있으므로 계수기가 0인 상태로 초기화된다.
  • empty
    • 이 세마포어는 버퍼에 남은 슬롯의 수를 추적한다.
    • 프로그램 포기에는 버퍼가 비어있으므로 초기값이 버퍼의 크기와 같다.
  • mutex
    • 이 뮤텍스는 공유 자원(버퍼)에 상호 배제를 강제하기 위한 목적으로 사용된다.

리더-라이터 문제

  • 모든 연산이 동등하지 않다
  • 아무리 많은 작업에서 같은 데이터를 동시에 다루더라도 그 연산이 데이터를 변경하지 않는다면 동시성 문제가 발생하지 않는다.
  • 데이터 변경만 배타적으로, 다시 말해 동시에 한 작업만 데이터롤 손을 댄다면 읽기는 동시에 여러 작업을 수행해도 문제가 없다.
  • 예를 들어, 도서관의 도서 목록이 공유 데이터인 상황을 보자.
  • 사용자는 목록을 읽고 사서 한두 명이 목록을 변경한다.
  • 목록에 대한 모든 접근이 임계 구역으로 간주되면 사용자들조차 자신의 차례를 기다려 도서관 목록에 접근해야 한다.
  • 이렇게 하면 목록에 접근하는 지연 시간이 길어지고 활용도가 떨어진다.
  • 그리고 사서들이 서로 간섭하지 않게 목록을 변경하는 중에는 변경 중인 정보에 대한 접근을 차단할 필요도 있다.
  • 리더는 제한없이 언제라도 공유자원을 읽을 수 있다.
  • 동시에 한 라이터만 공유 데이터를 변경할 수 있다.
  • 어떤 라이터가 데이터를 변경하고 있다면 리더 역시 데이터를 읽을 수 없다.
  • 이런식으로 모든 연산에 상호 배제를 적용하는 것보다 읽기는 동시에 여러 작업이 가능하도록 해주는 것이 훨씬 효율적이다.
  • 일반적으로 동작 중에는 동시에 여러 리더가 락을 가질 수 있다.
  • 그러나 공유 데이터를 변경하려는 스레드는 모든 리더가 락을 해제할 때까지 기다렸다가 쓰기 락을 획득하고 데이터를 변경한다.
  • 라이터 스레드가 공유 데이터를 변경하는 동안에는 리더 스레드 역시 대기한다.