질문의 역사
C++20 이전에 C++ 표준은 수동 동기화 없이 공유 스트림에 대한 형식화된 출력을 위한 스레드 안전 기능을 제공하지 않았습니다. std::cout는 sync_with_stdio를 통해 C 스트림과 동기화되도록 보장되었지만, 이로 인해 버퍼링이 방지되고 심각한 성능 저하가 발생했습니다. 개발자들은 일반적으로 모든 삽입에 std::mutex를 감싸지만, 이는 스레드를 완전히 직렬화하고 잠금이 하나의 코드 경로에서라도 잊혀질 경우 얽힘을 방지하지 못했습니다. 출력이 원자적으로 배치되는 고수준 추상화의 필요성이 고속 다중 스레드 로깅에 대해 매우 중요해졌습니다.
문제
표준 스트림 삽입 작업은 원자적이지 않습니다. 복합 유형에 대한 단일 operator<< 호출이 기본 std::streambuf에 대해 여러 sputc 또는 sputn 호출을 유발할 수 있으며, 동시에 실행되는 스레드는 이 세부 수준에서 문자가 얽힐 수 있습니다. 기존의 해결책인 std::stringstream 뒤에 잠금 출력을 사용하면 전체 버퍼의 추가 복사가 필요했습니다. 수동 잠금은 보일러플레이트를 추가하고, 뮤텍스 잠금 해제 전에 예외가 발생하면 교착 상태의 위험이 있었습니다.
해결책
C++20은 std::basic_osyncstream(문자형의 경우 std::osyncstream으로 별칭)을 도입하여 std::basic_syncbuf를 캡슐화합니다. 이 내부 버퍼는 모든 형식화된 출력을 로컬에서 누적합니다. **emit()**가 호출되면 — 명시적으로 또는 소멸자에 의해 — 감싸진 std::streambuf와 관련된 뮤텍스를 획득하고 전체 누적된 내용을 단일 연속 쓰기로 전송합니다. 이는 세밀한 문자 수준의 잠금을 거칠고 메시지 수준의 잠금으로 변환하여 다른 스레드가 방출 중에 문자를 교차할 수 없도록 보장합니다.
#include <syncstream> #include <iostream> #include <thread> #include <vector> void worker(int id) { std::osyncstream synced_out(std::cout); synced_out << "스레드 " << id << " 데이터 처리 중 "; // emit()은 여기서 자동으로 호출되어 전체 줄을 원자적으로 작성합니다. } int main() { std::vector<std::jthread> threads; for (int i = 0; i < 10; ++i) { threads.emplace_back(worker, i); } }
분산 데이터베이스 시스템이 여러 트랜잭션 스레드에서 중앙 감사 로그에 JSON 커밋 기록을 작성해야 했습니다. 각 레코드에는 트랜잭션 ID, 타임스탬프 및 상태 플래그가 포함되었습니다. 원자적 방출이 없으면 서로 다른 스레드의 중괄호와 따옴표가 섞여 손상된 JSON이 생성되어 하류 분석 파이프라인에서 구문 분석할 수 없게 되어 야간 배치 작업이 실패했습니다.
고려된 한 가지 해결책은 동시 읽기를 허용하지만 독점적인 쓰기를 허용하는 전역 std::shared_mutex였습니다. 장점: 익숙한 동기화 패턴. 단점: 작성자는 여전히 JSON 형식화 중 전체 기간 동안 직렬화 되었습니다. 커밋 폭풍 동안 높은 경쟁이 지연을 유발했으며, 잠금을 보유한 스레드가 잠금을 해제하기 전 예외를 발생시킬 경우 교착 상태 위험이 존재했습니다.
고려된 또 다른 접근 방식은 백그라운드 스레드에 의해 병합된 각 스레드의 로그 파일이었습니다. 장점: 쓰기 경로에서의 경쟁 없음; 트랜잭션 처리 중 잠금이 필요하지 않았습니다. 단점: 복잡한 로그 회전 및 파일 관리; 스레드 간의 시간 순서 손실; 여러 파일 핸들에서 증가된 디스크 I/O; 병합 스레드가 충돌할 경우 로그 유실의 잠재력.
팀은 std::osyncstream을 채택했습니다. 각 트랜잭션 범위는 공유 감사 std::ofstream을 감싸는 로컬 std::osyncstream을 생성합니다. JSON은 내부 버퍼에서 작성되고 범위 종료 시 원자적으로 방출됩니다. 이로 인해 잠금 보유 시간이 밀리초(즉, JSON 형식화 지속 시간)에서 마이크로초(즉, 버퍼 복사)로 줄어들고, 손상을 완전히 제거하며, **emit()**이 기본 파일 버퍼에 대한 접근을 직렬화하므로 연대순으로 나열된 순서를 유지합니다.
결과: 시스템은 로그 손상 없이 초당 100,000개 이상의 커밋을 지속할 수 있었고, 기록이 손상되지 않고 정렬된 상태로 유지되기 때문에 디버깅이 가능해졌습니다.
왜 std::osyncstream의 소멸자가 조건 없이 emit()을 호출하고, 기본 스트림이 예외를 발생시킬 경우 예외 안전성의 의미는 무엇입니까?
소멸자는 **emit()**을 호출하여 버퍼에 있는 데이터가 손실되지 않도록 보장합니다. **emit()**이 예외를 발생시키면(예: 디스크 가득 참) 소멸자는 암묵적으로 noexcept로 처리되므로 즉시 std::terminate가 호출됩니다. 후보자들은 종종 예외가 전파되거나 버퍼가 조용히 버려졌다고 믿습니다. 정확한 세부 사항은 std::basic_syncbuf가 emit_on_flush 플래그와 **get_wrapped()**를 통해 접근할 수 있는 오류 상태를 제공하지만, 소멸자는 조용한 데이터 손실이나 예외 누출보다 프로그램 종료를 우선시합니다.
명시적 플러시 작업(std::flush 또는 std::endl)은 std::osyncstream의 내부 버퍼링과 어떻게 상호 작용하며, 남용 시 성능이 뮤텍스 수준으로 왜 되돌아갑니까?
많은 후보자들은 std::flush가 즉시 기본 장치에 쓰인다고 생각합니다. std::osyncstream에서 std::flush는 내부 syncbuf가 방출을 준비하도록 트리거하지만 **emit()**을 호출하지는 않으며, 단지 emit_on_flush 상태를 설정합니다. 사용자가 각 삽입 후 수동으로 **emit()**을 호출하면(단위 버퍼링을 모방) 메시지별 잠금을 강제하여 배치 최적화를 무력화합니다. 효율성은 여러 삽입을 단일 emit() 호출로 배치하는 것에 의존합니다.
어떤 생애 제약이 std::streambuf를 std::osyncstream에 연결하며, 감싼 버퍼가 emit() 전에 파괴되면 어떤 정의되지 않은 행동이 발생합니까?
std::osyncstream은 감싼 std::streambuf의 소유권이 아닌 포인터를 저장합니다. 임시 std::ostringstream를 생성하고, 그 **rdbuf()**를 std::osyncstream에 전달한 다음 ostringstream가 osyncstream보다 먼저 범위를 벗어나면, 이후의 emit() 호출은 덜 여유 있는 포인터를 역참조하게 됩니다. 후보자들은 종종 참조 카운팅이나 osyncstream이 버퍼를 복사한다고 가정합니다. 표준은 프로그래머가 감싼 streambuf가 syncstream보다 오래 살아있도록 보장해야 한다고 규정하고 있습니다. 이는 std::string_view가 기본 문자열 저장소에 의존하는 것과 유사합니다.