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
- 이 뮤텍스는 공유 자원(버퍼)에 상호 배제를 강제하기 위한 목적으로 사용된다.
리더-라이터 문제
- 모든 연산이 동등하지 않다
- 아무리 많은 작업에서 같은 데이터를 동시에 다루더라도 그 연산이 데이터를 변경하지 않는다면 동시성 문제가 발생하지 않는다.
- 데이터 변경만 배타적으로, 다시 말해 동시에 한 작업만 데이터롤 손을 댄다 면 읽기는 동시에 여러 작업을 수행해도 문제가 없다.
- 예를 들어, 도서관의 도서 목록이 공유 데이터인 상황을 보자.
- 사용자는 목록을 읽고 사서 한두 명이 목록을 변경한다.
- 목록에 대한 모든 접근이 임계 구역으로 간주되면 사용자들조차 자신의 차례를 기다려 도서관 목록에 접근해야 한다.
- 이렇게 하면 목록에 접근하는 지연 시간이 길어지고 활용도가 떨어진다.
- 그리고 사서들이 서로 간섭하지 않게 목록을 변경하는 중에는 변경 중인 정보에 대한 접근을 차단할 필요도 있다.
- 리더는 제한없이 언제라도 공유자원을 읽을 수 있다.
- 동시에 한 라이터만 공유 데이터를 변경할 수 있다.
- 어떤 라이터가 데이터를 변경하고 있다면 리더 역시 데이터를 읽을 수 없다.
- 이런식으로 모든 연산에 상호 배제를 적용하는 것보다 읽기는 동시에 여러 작업이 가능하도록 해주는 것이 훨씬 효율적이다.
- 일반적으로 동작 중에는 동시에 여러 리더가 락을 가질 수 있다.
- 그러나 공유 데이터를 변경하려는 스레드는 모든 리더가 락을 해제할 때까지 기다렸다가 쓰기 락을 획득하고 데이터를 변경한다.
- 라이터 스레드가 공유 데이터를 변경하는 동안에는 리더 스레드 역시 대기한다.
chapter 10: 논블로킹 I/O
- 이번 장에서 배울 내용
- 분산 환경의 메시지 전달 IPC가 어떤 것인지 알아본다.
- 클라이언트-서버 애플리케이션이 무엇인지 알아본다.
- 입출력 연산에서 다중 스레드/프로세스를 활용할 때 한계점을 이해한다.
- 논블로킹 연산과 이를 이용해 입출력 중신 연산을 가리는 방법을 배운다.
10.1 분산 컴퓨팅의 세계
- 동시성의 범위는 이미 오래전에 단일 컴퓨터의 한계를 넘어섰다.
- 발전된 기술을 수백수천 대의 컴퓨터가 서로 연결된 세상을 가능케 했다.
10.2 클라이언트-서버 모델
- 클라이언트-서버 모델은 분산 컴퓨팅의 기본 모델이다.
- 이 모델은 클라이언트와 서버 이렇게 두 프로세스로 구성된다.
네트워크 소켓
- 네트워크 소켓에도 몇 가지 종류가 있지만 여기서는 TCP/IP 소켓을 다룬다.
- TCP/IP 소켓은 데이터 전송을 보장하는 특성이 있어 가장 널리 사용된다.
- 이 과정에서 정보를 주고받기 전에 두 프로세스의 동의가 필요하다.
- 이들 프로세스는 통신 세션이 지속되는 내내 접속을 유지한다.
- 네트워크 소켓은 운영체제에서 사용하는 네트워크 통신의 추상 이다.
- 소켓은 IP 주소와 포트 이렇게 두 가지 값을 갖느다.
C10k 문제
- 현대적인 서버 애플리케이션은 수백에서 수만에 이르는 클라이연터 요청(스레드)을 거의 지연 없이 동시에 처리할 수 있다.
- 운영체제는 스레드의 실행 준비 여부와 상관없이 모든 스레드에 CPU 시간을 배정한다.
- 예를 들어, 소켓으로 들어올 데이터를 기다리는 스레드는 데이터가 들어오기까지 수천 번 이상 CPU 시간을 배정받아 낭비한다.
- 스케줄러가 가진 CPU 타임이 10ms라고 가정해보자.
- 두 스레드가 있다마면 한 스레드가 배정받는 시간은 5ms이다.
- 다섯개라면? 2ms 스레드가 1000개라면?
- 이런 상황이라면 컨텍스트 스위칭에만 시간을 쓸 뿐 실질적인 일을 할 수 없다.
- 이를 해결하려면 타임 슬라이스의 크기를 일정 시간 이상으로 제한해야한다.
- 최저 크기를 2ms로 잡으면 1000개 스레드가 있다면 스케줄러에 필요한 CPU 시간은 2초가 된다.
- 스레드 10,000개를 동시에 실행하면 자신의 타임 슬라이스를 다 쓴다고 가정할 때 20초가 걸린다. 20초는 너무 길다.
- 이렇게 서버가 10,000개의 동시 접속을 처리하기 어려워지는 확장성의 문제를 C10k 문제라고 한다.
- 당시보다 기술이 발전하면서 현재는 c10m 문제로 확장됐다.
- 1000만개의 클라이언트 요청을 동시에 처리할 수 있어야 한다.
- 안타깝지만 스레드로는 c10k 문제를 해결할 수 없다.
chpater 11: 이벤트 기반 동시성
11.6 리액터 패턴
- 이벤트가 발생할 떄까지 기다렸다가 발생한 이벤트를 처리하는 이러한 형태의 이벤트 반복문은 매우 널리 쓰여 디자인 패턴으로까지 자리 잡았다.
- 이것이 바로 리액터 패턴이다.
- 리팩터 패턴이 성립하려면 이벤트 소스, 이벤트 핸들러, 동기 이벤트 디멀티플렉서, 리액터 구조가 있어야 한다.
- 이벤트 소스는 이벤트를 생성하는 요소다.
- 파일, 소켓, 타이머, 동기화 객체등이 있다.
- 이벤트 핸들러는 어떤 이벤트 소스에서 들어온 요청을 처리하는 콜백함수를 가리킨다.
- 동기 이벤트 디멀티플렉서는 운영체제가 제공하는 select 같은 이벤트 알림 시스템을 어렵게 부르는 말이다.
- 리액터, 다른 말로 이벤트 반복문은 이러한 과정 전체를 관장한다.
- 이벤트마다 해당 이벤트를 처리할 콜백을 등록하고
- 그 이벤트가 발생하면 등록된 이벤트 핸들러 또는 콜백에 이벤트 처리를 맡긴다.
11.8 여러 가지 입출력 모델
3