여러 실행 흐름은 같은 데이터를 어떻게 안전하게 다룰까? 스레드와 뮤텍스

반응형

스레드는 한 프로세스 안에서 코드를 실행하는 흐름이다. 각 스레드는 자기 실행 위치와 호출 스택을 가지지만, 힙 객체와 파일 같은 프로세스 자원은 함께 사용할 수 있다. 공유 덕분에 결과를 빠르게 주고받을 수 있지만, 실행 순서에 따라 결과가 달라지는 경쟁 상태도 생긴다.

동시성 설계의 핵심은 스레드 수를 늘리는 데 있지 않다. 어떤 데이터의 상태가 함께 바뀌어야 하는지, 그 상태를 누가 소유하는지, 다른 스레드는 언제 접근할 수 있는지를 정하는 데 있다.

증가 연산 하나도 여러 단계로 실행된다

공유 카운터를 두 스레드가 각각 한 번 증가시킨다고 하자. counter += 1은 겉으로는 한 문장이지만 실제로는 읽기, 계산, 쓰기로 나뉠 수 있다.

초기값 0

A: 0을 읽음
B: 0을 읽음
A: 1을 계산하고 씀
B: 1을 계산하고 씀

기대값 2, 실제값 1

두 스레드 모두 정상적으로 1을 더했다. 하지만 B는 A가 쓰기 전의 0을 기준으로 계산했고, 마지막 쓰기가 앞선 결과를 덮었다. 소스 코드의 줄 순서만 봐서는 이 실행 순서를 알 수 없다.

뮤텍스는 임계 구역에 한 스레드만 들어가게 한다

뮤텍스는 한 번에 한 실행 흐름만 보호 구역에 들어가게 하는 잠금이다. 카운터의 읽기·계산·쓰기를 하나의 임계 구역으로 묶으면 다음과 같이 실행된다.

A: 뮤텍스 획득
A: 0을 읽고 1을 씀
A: 뮤텍스 해제

B: 뮤텍스 획득
B: 1을 읽고 2를 씀
B: 뮤텍스 해제

뮤텍스가 보호하는 것은 변수 이름이 아니라 불변식이다. 잔액과 거래 건수가 항상 함께 바뀌어야 한다면 두 값을 바꾸는 전체 구간을 같은 규칙으로 보호해야 한다. 카운터 하나만 잠그고 관련 목록은 잠금 밖에서 바꾸면 상태 관계는 여전히 깨질 수 있다.

잠금 범위가 너무 넓으면 안전하지만 느려질 수 있다

한 개의 큰 뮤텍스로 모든 데이터를 막으면 규칙은 단순해진다. 대신 서로 다른 데이터를 다루는 스레드도 같은 잠금을 기다린다. 반대로 잠금을 잘게 나누면 동시에 일할 수 있는 범위는 커지지만, 어떤 순서로 여러 잠금을 잡아야 하는지가 복잡해진다.

잠금 안에서 네트워크 I/O나 긴 계산을 기다리는 것도 피해야 한다. 보호 상태를 잠깐 읽고 바꾸는 동안만 잠금을 잡고, 느린 작업은 가능한 한 밖에서 수행해야 다른 스레드의 대기가 줄어든다.

잠금 안: 공유 상태 확인과 짧은 변경
잠금 밖: 네트워크 요청, 파일 대기, 긴 계산

원자적 연산은 작은 상태 변경에만 충분할 수 있다

단순 카운터 증가는 하드웨어와 런타임이 제공하는 원자적 연산으로 처리할 수 있다. 다른 스레드가 중간에 끼어들 수 없는 한 연산으로 증가시키는 방식이다.

하지만 “재고를 줄이고 주문 번호를 목록에 추가한다”처럼 여러 값의 관계를 지켜야 하면 원자적 카운터 하나로는 부족하다. 원자적 연산이 있다는 사실과 업무 상태 전체가 원자적이라는 사실을 구분해야 한다.

조건 변수는 기다릴 상태와 깨울 시점을 연결한다

작업 큐가 비었을 때 소비자 스레드가 계속 확인하면 CPU를 낭비한다. 조건 변수는 “큐가 비어 있으면 잠들고, 생산자가 작업을 넣으면 깨운다”는 규칙을 만든다.

소비자: 잠금 획득 → 큐가 비었으면 기다림
생산자: 잠금 획득 → 작업 추가 → 소비자를 깨움
소비자: 다시 잠금 획득 → 큐 상태를 재확인

깨어났다고 작업이 반드시 남아 있다고 가정하면 안 된다. 다른 소비자가 먼저 가져갔거나 예기치 않은 깨움이 있을 수 있다. 그래서 조건은 한 번의 if가 아니라, 잠금 안에서 다시 확인하는 반복 규칙으로 다룬다.

공유를 줄이면 잠글 대상도 줄어든다

모든 결과를 여러 스레드가 직접 같은 목록에 쓰지 않아도 된다. 각 스레드가 자기 결과를 만들고 마지막에 한 실행 흐름이 모으거나, 메시지로 데이터의 소유권을 넘길 수 있다.

공유 상태를 없애면 잠금 실수와 경쟁 상태를 함께 줄일 수 있다. 다만 결과를 모을 때 중복과 누락, 작업 실패를 확인해야 한다. 공유를 피한다는 것은 책임을 없애는 것이 아니라 소유자를 분명히 만드는 방법이다.

스레드 시작과 작업 완료는 같은 상태가 아니다

스레드를 만들었다고 작업이 끝난 것은 아니다. 일부 스레드가 오류로 종료되거나 취소될 수 있다. 결과를 사용하는 쪽은 각 작업의 성공·실패·취소 상태를 확인하고, 필요한 스레드가 모두 끝난 뒤 다음 단계로 가야 한다.

이 경계를 놓치면 일부 결과가 빠진 상태로 집계를 시작하거나, 실패한 작업을 성공으로 처리할 수 있다. 동시 실행에서는 데이터 잠금뿐 아니라 작업 수명도 상태로 관리해야 한다.

이해 확인 질문과 답변

두 스레드가 각각 1을 더했는데 왜 결과가 1일 수 있을까?

둘 다 같은 이전 값 0을 읽고 각각 1을 쓴 결과가 겹치기 때문이다. 읽기·계산·쓰기가 하나의 원자적 동작이 아니라는 점이 경쟁 상태를 만든다.

뮤텍스는 무엇을 보호해야 할까?

변수 하나보다 함께 유지돼야 하는 상태의 관계를 보호해야 한다. 여러 값이 하나의 불변식을 이룬다면 그 관계를 확인하고 바꾸는 구간을 같은 잠금 규칙으로 묶어야 한다.

잠금을 오래 잡으면 왜 문제가 될까?

다른 스레드가 같은 상태를 사용하지 못하고 기다리기 때문이다. 잠금 안에서는 필요한 상태 변경만 하고 느린 I/O와 계산은 가능한 한 밖으로 옮긴다.

스레드를 많이 만들면 항상 빨라질까?

아니다. CPU 코어보다 많은 계산 스레드는 전환 비용을 늘릴 수 있고, 같은 잠금을 기다리는 스레드는 수가 늘어도 병목을 공유한다. 작업 성격과 공유 정도를 함께 봐야 한다.

AI에게 이렇게 요청할 수 있다

두 스레드가 공유 카운터를 증가시켜 결과가 1이 되는 인터리빙을 보여 줘. 뮤텍스로 읽기·계산·쓰기를 보호하는 과정, 잠금 안의 느린 I/O, 조건 변수가 깨어난 뒤 상태를 다시 확인하는 이유를 설명해 줘.

스레드를 이해했다면 “동시에 실행한다”에서 끝나지 않고, 어느 상태를 공유하며 어떤 관계를 잠그고 누가 완료와 실패를 확인하는지까지 설명할 수 있어야 한다.

 

반응형
KEEP READING
카테고리 전체 보기 →

댓글