Devin.KR

C 공유 버퍼 보호

100분 안팎

학습 목표

pthread 생산자·소비자 큐에 mutex를 적용하고 순번·누락을 검증합니다.

개념

큐의 값과 인덱스를 하나의 약속으로 보호합니다

생산자는 측정 결과를 준비하고 소비자는 그 결과를 전송합니다. 둘이 같은 배열을 사용할 때 배열 칸만 보호하면 충분하지 않습니다. tail은 다음에 넣을 칸, head는 다음에 꺼낼 칸, count는 현재 메시지 수입니다. 메시지 복사와 인덱스 변경 사이에 다른 스레드가 끼어들면 아직 완성되지 않은 본문을 읽거나 메시지 하나를 두 번 꺼낼 수 있습니다. 이번 실습에서는 이 관계 전체를 mutex로 보호하고 순번과 본문을 함께 검사합니다.

Message는 seq, raw, record를 값으로 보관하며 record는 앞 모듈의 timestamp_ms·temp_mc·adc_mv입니다. 지역 구조체의 주소만 큐에 넣지 않습니다. 생산자 함수가 다음 측정으로 같은 변수를 덮어써도 큐 안의 이전 메시지는 그대로 있어야 합니다. 포인터를 쓰는 설계라면 수명과 소유권 반환 계약이 추가로 필요합니다. 이 실습은 고정 크기 값 복사로 경계를 단순하게 유지합니다.

용량 16 링 큐의 불변식을 정합니다

slots는 16개이고 count는 0부터 16까지입니다. head와 tail은 각각 0부터 15까지이며 한 번 이동할 때 16으로 나눈 나머지를 취합니다. head와 tail이 같아도 빈 상태와 가득 찬 상태가 모두 가능하므로 count로 구분합니다. 빈 슬롯 하나를 남기는 방식과 달리 이 구현은 16칸 전체를 사용합니다. capacity를 16으로 표시하고 실제로는 15건만 넣는 코드라면 실습 계약을 어긴 것입니다.

mq_push는 mutex를 얻은 뒤 포화·종료 조건을 판단하고, 가능하면 Message 전체를 slots의 tail 칸에 복사한 다음 tail·count·최고점 high를 변경합니다. 준비 표시인 count를 먼저 증가시키지 않습니다. mq_pop도 같은 mutex 아래에서 slots의 head 값을 출력 객체로 복사한 다음 head·count를 변경합니다. 읽기 경로에도 잠금이 필요합니다. 생산자만 보호해 놓고 소비자는 안전하게 읽을 것이라고 가정하지 않습니다.

자료형 MessageQueue 내부의 mutex는 큐마다 하나입니다. 생산자와 소비자가 서로 다른 mutex를 잠그면 상호 배제가 되지 않습니다. q를 생성한 뒤 스레드 시작 전에 mutex와 조건 변수를 초기화하고, 실행 중인 큐 구조체를 통째로 복사하지 않습니다. pthread 객체는 일반 메시지처럼 값 복사할 대상이 아닙니다. 함수가 보장하는 수명은 초기화부터 모든 사용자의 종료·join 이후 파괴까지입니다.

기다림과 과부하 정책을 분리합니다

mq_push의 wait가 1이면 가득 찬 큐에서 빈자리를 기다립니다. wait가 0이면 기다리지 않고 새 메시지를 버리는 DROP_NEW 정책입니다. 이때 기존 16건은 순서를 유지하고 dropped만 증가합니다. 정상 부하 pthread 검사는 생산자가 wait=1로 10000건을 넣으므로 전부 전달해야 합니다. 포화 검사에서는 소비를 멈춘 채 17번째를 넣어 반환 0과 dropped=1을 확인합니다. 두 정책을 섞어서 정상 검사에서 누락을 허용하지 않습니다.

소비자의 wait가 1이면 비어 있는 동안 readable 조건 변수에서 기다립니다. 대기 조건은 if가 아니라 while로 검사합니다. 알림이 왔더라도 다시 잠금을 얻기 전에 다른 소비자가 메시지를 가져갔거나 조건 없는 깨움이 있을 수 있습니다. 조건 변수의 알림 자체를 메시지로 세지 않고 count가 실제로 양수인지 확인합니다. wait=0은 비면 즉시 반환하므로 단일 루프의 결정적 통합 모델에 사용하기 좋습니다.

pthread_cond_wait는 같은 mutex를 잠근 상태에서 호출합니다. 대기하는 동안 mutex를 풀어 다른 스레드가 조건을 바꿀 수 있게 하고, 반환할 때 다시 mutex를 소유합니다. 별도로 unlock한 뒤 wait를 호출하는 방식은 이 계약과 다릅니다. 큐에 한 건 넣으면 readable을 알리고, 한 건 꺼내면 writable을 알립니다. signal은 조건을 만족하게 만드는 코드와 함께 읽어야 하며 알림을 저장하는 카운터처럼 다루지 않습니다.

닫기와 취소의 의미를 정합니다

mq_close는 closed를 켜고 두 조건 변수의 대기자를 모두 깨웁니다. 이후 새 push는 거절하지만 이미 들어온 메시지는 pop으로 끝까지 꺼낼 수 있습니다. count가 0이고 closed가 참이면 기다리는 pop도 반환 0으로 종료합니다. 생산자는 10000건을 넣은 뒤 close를 호출하고 소비자는 빈 종료 큐를 관찰한 뒤 반환합니다. main은 두 pthread_join을 완료한 다음 mutex와 조건 변수를 파괴합니다.

mq_clear는 대기 메시지를 취소하고 제거 수를 돌려줍니다. 이것은 close와 다릅니다. clear 뒤 큐는 계속 열려 있고 새 측정을 받을 수 있습니다. 복구 통합에서는 분리나 정지로 취소한 수를 CANCEL 카운터에 더하며, 용량 초과 dropped와 섞지 않습니다. 최고점과 dropped는 누적 진단 값으로 남깁니다. 진단 통계를 읽을 때에도 실행 중이라면 동일한 mutex 보호가 필요합니다. 테스트에서 직접 읽는 통계는 스레드가 없거나 join을 마친 시점의 값입니다.

순번과 본문을 같은 테스트로 검증합니다

압축의 test_queue.c는 먼저 단일 스레드로 빈 큐·16건 포화·17번째 거절·FIFO·여러 번 순환·clear·close 뒤 drain을 검사합니다. 다음에는 실제 pthread 생산자와 소비자를 실행합니다. 생산자는 seq 0부터 9999까지를 순서대로 넣고 timestamp와 temp_mc도 순번에서 구성합니다. 소비자는 매번 예상 seq와 모든 검사 필드를 비교합니다. 합계만 맞으면 중복과 누락이 서로 상쇄될 수 있으므로 순서와 값의 관계를 함께 봅니다.

starter는 mutex·조건 변수의 수명과 대기 골격을 제공하지만 Message를 복사한 뒤 seq를 0으로 덮어씁니다. 컴파일은 성공하고 큐 경계 검사는 일부 통과하지만 out.seq==i와 스레드 본문 비교가 실패합니다. message_queue.c의 TODO 구간에서 전체 메시지 보존을 완성합니다. 이 안전한 결함은 데이터 레이스를 실행시키지 않으면서 보호 구간 안의 메시지 계약도 검증해야 한다는 점을 보여 줍니다. 고친 뒤 잠금 경계가 줄어들지 않았는지 별도로 검토합니다.

make test는 gcc -std=c11 -Wall -Wextra -Werror와 -pthread로 빌드합니다. FAIL line=숫자: 조건식은 test_queue.c의 실패한 요구사항입니다. undefined symbols는 pthread 연결 옵션이나 소스 목록을, pthread error=숫자는 초기화·잠금·대기 함수의 오류 반환을 먼저 확인합니다. pthread 함수는 실패 코드를 직접 반환하므로 errno만 읽지 않습니다. 테스트의 delivered=10000은 꺼낸 수를 뜻하며 옆의 failures가 0인지까지 봐야 내용이 맞다고 판단할 수 있습니다.

PC 증거를 ISR 설계로 확대하지 않습니다

pthread mutex는 PC 스레드 문맥의 도구입니다. 가상 ISR이나 실제 ISR 안에서 이 큐의 blocking 호출을 실행하도록 옮기지 않습니다. 실제 RTOS에는 ISR 전용 큐 호출과 허용 인터럽트 우선도 규칙이 있으며 대상별로 확인해야 합니다. 고우선도 태스크가 낮은 태스크의 잠금을 기다리는 우선순위 역전도 이 PC 검사로 해결되었다고 말할 수 없습니다. 잠금은 짧게 유지하고 UART·SPI 처리는 메시지 복사 뒤 잠금 밖에서 수행합니다.

volatile이나 고정 sleep을 잠금 대용으로 추가하지 않습니다. 통과 로그는 특정 컴파일러와 실행 조건에서 검사한 기능의 증거이며 모든 가능한 스케줄의 수학적 증명은 아닙니다. 코드를 검토할 때에는 slots·head·tail·count에 대한 모든 접근이 같은 mutex 아래인지, 대기 조건과 종료 조건이 재검사되는지 함께 확인합니다. 조건 변수의 계약은 POSIX 공식 명세에서, 메모리 동기화는 POSIX 일반 개념에서 확인할 수 있습니다. 확장 예제는 더 읽기의 C 서재 장으로 이어집니다.

따라하기

시작본의 실패 계약 읽기

starter의 Makefile 폴더에서 아래 명령을 실행합니다. 테스트 종료 코드와 첫 실패 조건, 요약을 출력합니다. 135개 중40개 실패이며 seq==i 조건을 따라 message_queue.c의 TODO를 찾습니다.

python3 - <<'PYCODE'
import subprocess
p = subprocess.run(['make', '-s', 'test'], text=True, capture_output=True)
print('exit='+str(p.returncode))
print(next((line for line in p.stderr.splitlines() if line.startswith('FAIL')), 'no assertion failure'))
print(p.stdout.strip())
PYCODE

실행 결과

exit=2
FAIL line=30: out.seq==i
queue: 135 checks, 40 failures; delivered=10000

전체 메시지 보존과 보호 구간 검토

message_queue.c에서 q->slots[q->tail]=m 뒤의 seq 덮어쓰기를 제거합니다. 이 복사는 tail·count 변경과 같은 mutex 아래에 둡니다. 잠금 밖으로 옮기지 않습니다. 읽기쪽 while 조건과 close의 broadcast도 소스에서 확인합니다.

q->slots[q->tail]=m;
q->tail=(q->tail+1)%MESSAGE_CAPACITY;
q->count++;

같은 테스트로 다시 검사

수정한 starter에서 실행합니다. 완성본에서 직접 얻은 요약은 다음과 같으며 순번·payload·FIFO·경계 검사가 모두 맞아야 실패0입니다.

make -s test

실행 결과

queue: 135 checks, 0 failures; delivered=10000

확인 문제

실습

embedded-shared-buffer 압축을 풀고 make test를 실행합니다. message_queue.c의 TODO에서 Message 전체를 보존하도록 고칩니다. 복사와 head·tail·count 갱신의 mutex 경계를 검토하고 테스트 파일은 유지합니다. 135개 조건과 pthread 10000건 순번·본문이 모두 맞아야 합니다. 정상부하 wait=1, 포화시험 wait=0, close 뒤 drain, join 뒤 destroy의 이유를 README에 적습니다.

시작 코드·테스트 내려받기

실행 명령

make test

기대 결과

queue: 135 checks, 0 failures; delivered=10000; 종료 코드 0

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 배열과 포인터를 사용할 때 메모리 오류를 확인하는 방법을 설명해 주시면 됩니다.
  • 생산자·소비자 큐에서 조건 변수를 while로 기다리는 이유와 종료 절차를 설명해 주세요.