CodeGym /행동 /C++ SELF /공유 데이터: 무엇을 공유 리소스로 볼 것인가

공유 데이터: 무엇을 공유 리소스로 볼 것인가

C++ SELF
레벨 68, 레슨 4
사용 가능

1. 들어가며

처음에 «공유 데이터»라는 말을 들으면 보통 한 장면이 떠오릅니다: «두 함수가 같은 변수를 만진다». 맞는 말이긴 하지만 너무 좁게 보는 관점입니다.

실무에서 공유 리소스는 여러 스레드가 사용할 수 있고 동시에 상태를 가지거나 관찰 가능한 동작을 하는 모든 것입니다(예: 콘솔 출력). 여기서 놀라운 일이 시작됩니다: 공유 리소스는 단순한 int counter뿐만 아니라 std::cout, 디스크의 파일, 소켓, 심지어 «공유된 작업 순서»도 해당됩니다.

직관을 잡는 것이 중요합니다: 두 스레드가 같은 «세계의 객체»(메모리/파일/콘솔/장치)에 영향을 줄 수 있다면, 당신은 다음 질문에 대한 답을 가져야 합니다: 어떤 규칙으로 이것을 공유할 것인가? 답이 없다면, 상황은 단지 «약간 위험»이 아니라 «언젠가는 큰 문제»로 이어집니다.

공유 리소스의 분류

우리가 «리소스»라고 말할 때, 모든 것을 정리해두면 좋습니다. 그렇지 않으면 data race를 오직 counter에서만 찾게 되고, 실제로는 로그에서 문제가 발생할 수 있습니다. 실용적인 공유 리소스 지도를 살펴보겠습니다.

아래 표는 무엇이 «공유»인지, 그리고 왜 깨질 수 있는지를 빠르게 분류하는 데 도움이 됩니다.

공유 리소스 분류 예시 무엇이 잘못될 수 있는가 전형적인 '초기 대응' (mutex 없이)
공유 메모리 (RAM의 데이터)
int, std::string, std::vector<Task>
Data race, UB, '가끔은 동작함' 동시에 건드리지 않기; 교환은 경계에서만(예: join() 이후)
출력 (I/O)
std::cout, 로그 파일
섞인 줄, 메시지 일부 손실 메시지를 문자열에 조립해 한 번에 출력하기; 한 스레드에서만 출력하기
디스크의 파일
두 스레드에서의 std::ofstream
손상된 파일, 섞인 문자열 한 스레드에서만 기록하기(임시적)
외부 서비스 데이터베이스, API 상태가 예기치 않게 변경됨, 작업 충돌 변경을 하나의 '소유자'로 모으기 (한 스레드가 결정)
시간과 이벤트 순서 '먼저 계산하고, 그다음 보여주기' Race condition: '보여주기'가 '계산'보다 먼저 일어남 명시적으로 '완료' 지점을 정하기 (join())하고 지연에 의존하지 않기

중요한 심리적 요점: std::cout도 공유 리소스입니다. 이것은 '사소한 것'이 아닙니다: 많은 사람들이 경쟁성을 처음으로 접하는 계기가 콘솔 출력의 줄들이 갑자기 섞이는 현상입니다.

특히 흥미로운 점은 표준 라이브러리 자체가 컨테이너 주변의 data race 회피를 진지하게 다룬다는 것입니다 — 컨테이너에 대한 요구사항과 문구들이 data race avoidance 관점에서 논의됩니다.

공유 리소스는 임시방편이 아니라 설계가 필요한 이유

많은 사람들이 이렇게 하고 싶어합니다: «아, 레이스가 있으니 지금 sleep_for(10ms)(또는 '그냥 기다려')를 넣으면 안정화되겠지». 불행히도, 이것은 금요일 저녁에만 나타나는 버그의 세계로 가는 고전적인 길입니다.

문제는 멀티스레딩에서 방해하는 것은 속도만이 아니라 스케줄러의 불확실성이라는 점입니다. 스레드는 언제든지 명령 사이에서 전환될 수 있습니다. 그러므로:

누가 언제 공유 리소스를 만질지에 대한 약속이 없다면, 당신은 race condition을 얻게 됩니다: '때로는 제대로 되고, 때로는 안 된다'.

같은 메모리를 동시에 읽고 쓰는데 프로토콜이 없다면 — data race가 발생합니다. C++에서는 이것이 특히 심각합니다: 공식적으로 프로그램은 UB에 빠지며, 컴파일러와 프로세서는 당신이 원치 않는 방식으로 행동할 수 있습니다.

'로직이 깨진 것'과 '언어가 규칙 밖이라고 선언한 것'의 차이를 느끼는 것이 중요합니다. Race condition은 테스트와 디버깅으로 잡을 수 있지만, data race는 몇 주간 잡히지 않다가 전혀 관련 없어 보이는 지점에서 이상한 크래시로 돌아옵니다.

2. 동기화는 소유 경계에서 시작한다

앞으로 mutex를 배우기 전에 더 근본적인 생각을 받아들여야 합니다: 동기화는 도구가 아니라 약속이라는 것. 도구들(뮤텍스, 원자 변수, 조건 변수)은 약속을 기술적으로 지키는 '방법'입니다. 먼저 '무엇에 대해 약속하는가'를 이해해야 합니다.

좋은 멀티스레드 설계는 거의 항상 세 가지 질문에 답합니다.

첫째 질문 — 누가 데이터의 소유자인가. 소유자는 상태를 변경할 권한이 있는 주체입니다. 소유자가 둘이라면, 미리 프로토콜을 만들어야 합니다. 그렇지 않으면 data race의 문을 연 셈입니다.

둘째 질문 — 누가 읽는가. 읽기도 옆에서 쓰기가 있다면 위험할 수 있습니다. 그래서 종종 읽는 쪽은 '스냅샷'(복사본)을 받거나 작성자가 작업을 끝낸 뒤에만 읽기로 약속합니다.

셋째 질문 — 교환의 경계는 어디인가. 지금 바로 사용할 수 있는 가장 단순한 경계는 join()입니다: 스레드가 종료될 때까지 다른 스레드는 그 결과를 건드리지 않습니다. 초보자 관점에서는 이것이 거의 '교수에게 과제를 제출하고 나서야 점수를 받는다'와 같습니다. 누군가가 작업 도중에 중간 결과를 훔쳐보는 일은 없습니다.

이를 소유 구조로 그림으로 나타내 보겠습니다:

flowchart LR
    M[main thread<br/>소유자 tasks] -->|스냅샷 생성| S[local snapshot<br/>tasks 복사본]
    S --> W[worker thread<br/>복사본만 사용]
    W -->|결과 생성| R[report string]
    R -->|join 이후| M

아이디어는 단순합니다: 공유 리소스(tasks)가 두 번째 스레드를 시작했다고 해서 자동으로 '공유'가 되는 것은 아닙니다. 우리는 설계를 통해 tasks가 한 소유자의 통제 아래 남도록 조직합니다.

4. mutex 없이 사용하는 전략: 변경 가능한 것을 공유하지 않기

이 단계에서는 아직 완전한 '동기화 무기고'가 없습니다(그 부분은 이후에 다룹니다). 좋은 소식은 많은 문제를 안전하게 해결할 수 있다는 점입니다. 전혀 공유 메모리 싸움에 들어가지 않고도 가능합니다.

Thread confinement: 데이터는 하나의 스레드에만 존재

가장 단순한 전략은 거의 너무 어린아이 수준처럼 들립니다: '한 스레드에게 맡겨라'. 그리고 의외로 이게 잘 동작합니다.

예를 들어 구조체(예: std::vector<Task> tasks)가 있으면, 오직 main 스레드만 그것을 변경한다고 약속할 수 있습니다. 백그라운드 스레드는 tasks를 전혀 건드리지 않거나 복사본(스냅샷)을 받습니다. 이를 '스레드에 의한 데이터 격리'라고 부르며, 데이터가 한 스레드 내부에 '잠겨' 있는 것처럼 합니다.

이 접근의 장점은 단순하고 거의 깨지지 않는다는 것입니다. 단점은 백그라운드 처리에서 상태를 변경하고 싶을 때가 있다는 점입니다. 하지만 뮤텍스와 큐가 없을 때는 이 방법이 정직하고 안전한 선택입니다.

Snapshot: 공유 접근 대신 복사

스레드가 무언가를 읽어야 한다면, 그 스레드는 복사본을 읽게 하세요. 복사는 비용이 듭니다. 하지만 멀티스레딩 초보 단계에서는 복사가 data race 디버깅보다, 그리고 무엇보다 신경 건강에 더 저렴합니다.

간단한 코드 아이디어: 우리는 tasks를 복사하여 snapshot에 넣고 스레드에서 그것으로 작업합니다.

#include <vector>
#include <string>

struct Task {
    int id{};
    std::string text;
    bool done{};
};

std::vector<Task> make_snapshot(const std::vector<Task>& tasks) {
    return tasks; // 복사본: snapshot은 독립적으로 존재함
}

여기서 중요한 점은 snapshot이 더 이상 main 스레드에서 일어나는 tasks의 변화에 의존하지 않는다는 것입니다.

종료 후 결과 교환: join을 경계로 사용

또 다른 '철저한' 전략은 다음과 같습니다: 스레드는 결과를 계산해 변수에 기록하고, 다른 스레드는 오직 join() 이후에만 그 변수를 읽습니다.

이것은 마법이 아니라 규율입니다: 스레드가 작업하는 동안 그 결과는 초안으로 간주되고 건드리지 않습니다.

협력적 중지: stop_token이 올바른 이유

std::stop_token의 흥미로운 점은 이것이 멀티스레딩을 염두에 두고 처음부터 설계된 '공유 메커니즘'의 예라는 것입니다. 외부 코드는 중지를 요청할 수 있고, 내부 코드는 요청 여부를 확인할 수 있습니다.

즉, stop_token은 우리가 단순히 bool cancel을 공유한 것이 아닙니다. 이는 멀티스레딩 규칙에 따라 동작하도록 설계된 특수 타입이며 협력적 취소를 위한 채널로 의도되었습니다. 표준에는 stop token/stopsource와 관련된 섹션들이 실제 사용을 반영해 정정·명확화되는 경우가 있습니다.

실용적 교훈: '단순히 플래그를 공유하면 된다'고 생각되면 멈추고 다시 생각하세요. 종종 올바른 방법은 이미 멀티스레딩 모델이 정확한 메커니즘을 사용하는 것입니다. 직접 '공유된 bool'을 발명하지 마세요.

5. 예제: TaskBoard에서 백그라운드 리포트

과정 중 콘솔 애플리케이션 TaskBoard를 작성했다고 가정합시다: 작업은 std::vector<Task>에 저장되고, 작업 추가, 완료 표시, 목록 출력 기능이 있습니다. 이제 '완료된 7/10' 같은 리포트를 별도 스레드에서 계산하도록 만들고 싶습니다. 실제로 리포트 계산은 무겁고(파일 읽기, 집계, 파싱 등) 별도 스레드가 합리적입니다.

기본: Task 모델과 리포트 생성 함수

먼저 작업 목록에서 리포트 문자열을 만드는 작은 함수를 보겠습니다. 아직 스레드는 사용하지 않습니다.

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string text;
    bool done{};
};

std::string build_report(const std::vector<Task>& tasks) {
    int done_count = 0;
    for (const auto& t : tasks) if (t.done) ++done_count;
    return "Done: " + std::to_string(done_count) + "/" + std::to_string(tasks.size());
}

주의: 함수는 const std::vector<Task>&

나쁜 예: tasks를 공유하고 '읽기만 한다'고 믿기

이제 초보자가 먼저 떠올리는 것을 해보겠습니다: 스레드를 시작해서 tasks를 읽는 동안 main 스레드는 계속 그것을 변경합니다.

#include <iostream>
#include <thread>
#include <vector>

int main() {
    std::vector<Task> tasks; // main이 tasks를 변경할 수 있음

    std::thread t([&] {
        std::cout << build_report(tasks) << '\n'; // 위험: tasks가 공유되고 변경 가능함
    });

    tasks.push_back(Task{1, "Learn threads", false}); // main이 tasks를 변경함
    t.join();
}

겉보기에는 무해해 보입니다: '스레드가 그냥 출력만 한다'. 실제로는 우리가 프로토콜 없이 공유 가능한 변경 리소스를 만들었습니다. 스레드가 tasks를 읽는 동안 main은 push_back을 호출합니다. 이는 data race와 이상한 동작으로 가는 고전적 경로입니다.

여기서 중요한 사실: 표준 라이브러리는 한 스레드가 크기를 변경하는 동안 다른 스레드가 같은 컨테이너를 읽는 것이 안전하다고 보장하지 않습니다. 컨테이너 주변의 요구사항과 data race avoidance 논의가 빈번한 이유가 바로 여기에 있습니다.

좋은 예: snapshot을 만들어 복사본으로 작업하기

설계를 고칩시다: main 스레드는 tasks의 소유자로 남고, worker는 snapshot 복사본을 받아서 그로부터 리포트를 만듭니다.

#include <iostream>
#include <thread>
#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, "Learn threads", true});

    const std::vector<Task> snapshot = tasks; // 복사본

    std::thread t([snapshot] {
        std::cout << build_report(snapshot) << '\n'; // Done: 1/1
    });

    tasks.push_back(Task{2, "Write report", false}); // 원본을 변경하지만 스레드는 보지 못함
    t.join();
}

여기에는 약간의 비용(복사)이 있지만 큰 장점이 있습니다: 스레드 전환 순서에 의존하지 않으며, 스레드는 '동결된 사진' 데이터로 작업합니다.

좋은 예: join 후에 리포트 출력

또 다른 안전한 방법은: 스레드가 리포트 문자열을 만들고 main이 종료를 기다린 다음 출력하는 것입니다. 핵심은 동시 접근이 없다는 것입니다: 스레드가 report를 쓰는 동안 main이 그걸 읽지 않습니다.

#include <iostream>
#include <thread>
#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, "Learn join()", true});

    std::string report;

    std::thread t([&] {
        report = build_report(tasks); // main이 tasks와 report를 동시에 건드리지 않는다면 안전
    });

    t.join();
    std::cout << report << '\n'; // Done: 1/1
}

주의할 점: 이 방법은 main이 스레드가 리포트를 만드는 동안 tasks를 변경하지 않을 때만 안전합니다. 다시 말해, 우리는 결국 '약속' 문제로 되돌아갑니다: snapshot을 사용하거나 작업이 끝날 때까지 변경을 중지해야 합니다.

실제 애플리케이션에서는 '변경을 멈추는' 것이 불편한 경우가 많아서, 스냅샷이 더 실용적일 때가 많습니다.

진행 상황(프로그레스) 문제: 다음 단계

보통 여기쯤에서 'worker가 progress를 업데이트하고 main이 퍼센트로 보여주자'는 욕구가 생깁니다. 그러면 공유된 int progress가 두 스레드에 의해 동시에 조작되는 상황이 되곤 합니다.

안타까운 소식: progress가 평범한 int라면 읽기/쓰기를 프로토콜 없이 동시에 하면 data race가 발생합니다. 좋은 소식은 당신이 동기화가 필요하다는 것을 느낀 것은 옳다는 점입니다 — 올바른 도구와 패턴은 이후 강의에서 다룰 예정입니다.

오늘은 정직한 제한을 받아들이는 것이 좋습니다: 스레드가 스스로 진행 상황을 출력하게 하거나(그렇다면 출력이 섞일 수 있음), 완료 후에 진행 상황을 보여주기. 보기에는 안 예쁘지만 정확합니다.

6. 흔한 실수

실수 #1: 공유 데이터는 변수만이라 생각하고 std::cout은 공유가 아니라고 간주.
초보자는 종종 문제를 메모리에서만 찾고 출력도 공유 리소스라는 사실을 잊습니다. 결과는 섞인 줄들이고, 이들을 지연으로 해결하려고 합니다. 더 현명한 접근은 처음부터 출력이 공유임을 인정하는 것입니다. 여러 스레드가 동시에 쓴다면 줄의 순서는 보장되지 않습니다.

실수 #2: '나는 단지 읽기만 하므로 안전하다'고 생각.
읽기는 옆에서 쓰기가 없다면 안전합니다. 하지만 한 스레드가 std::vector에 push_back을 하고 다른 스레드가 같은 순간에 요소를 읽는다면, 이는 안전하지 않으며 data race가 될 수 있습니다. '읽기는 변경하지 않는다'는 논리는 컨테이너 내부 구조가 변경될 수 있다는 점에서 충분하지 않습니다.

실수 #3: 수명 보장이 명확하지 않은 객체의 레퍼런스를 스레드에 전달.
심지어 join()을 사용하더라도, 어떤 것을 참조로 캡처하면 스코프를 벗어나기 전에 스레드가 끝나지 않을 것이라는 보장이 없으면 문제가 됩니다(특히 detach()를 사용했던 경우). 초보자를 살리는 규칙: 기본적으로 스레드에는 값을 전달하거나 복사 스냅샷을 사용하세요.

실수 #4: sleep_for로 동기화하려 시도.
지연은 데이터 접근 규칙을 만들지 않습니다. 단지 버그가 드러날 확률을 바꿀 뿐입니다. 때로는 더 나쁠 수 있습니다: 버그가 '희귀'해져서 정말로 추적하기 어려워집니다. '먼저 하고 그 다음에 사용'이 필요하다면, 현재 수준에서는 명시적 경계(join())나 복사본 전달이 정직한 방법입니다.

실수 #5: '빠르게 공유 플래그 bool cancel을 만들자'.
거의 항상 이것은 플래그의 동시 읽기/쓰기로 인한 data race를 초래합니다. 협력적 중지가 필요하면 이를 위해 설계된 std::stop_token을 사용하세요. 더 복잡한 조정이 필요하다면 이는 다음 강의에서 다룰 주제입니다.

1
과제
C++ SELF, 레벨 68, 레슨 4
잠금
용병의 합
용병의 합
1
과제
C++ SELF, 레벨 68, 레슨 4
잠금
두 개의 리포트
두 개의 리포트
1
과제
C++ SELF, 레벨 68, 레슨 4
잠금
작업 스냅샷
작업 스냅샷
1
과제
C++ SELF, 레벨 68, 레슨 4
잠금
블록별 통계
블록별 통계
1
설문조사/퀴즈
thread/jthread, 레벨 68, 레슨 4
사용 불가능
thread/jthread
thread/jthread
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION