두 process가 같은 shared memory segment에 attach했다고 해서 서로 안전하게 data를 읽을 수 있는 것은 아닙니다. 이 실습에서는 독립적으로 실행한 server와 client가 숫자 하나를 주고받습니다. Client가 21을 제출하면 server가 42로 바꾸고, client가 결과를 확인한 뒤에만 server가 semaphore를 정리하도록 하겠습니다.
코드는 책의 listing과 별도로 작성했습니다. Linux 4.17 이상과 glibc 2.27 이상을 실습 묶음의 목표 범위로 삼으며, 같은 architecture와 libc ABI로 build한 server/client 한 쌍을 전제합니다. API 근거는 Linux man-pages 6.19입니다. System V IPC와 process-shared POSIX semaphore를 사용할 수 있는 kernel 환경이 필요합니다.
Segment를 찾는 일과 data를 주고받는 일을 나눕니다
Server는 shmget(IPC_PRIVATE, ..., 0600)으로 새 segment를 만듭니다. 여기서 IPC_PRIVATE는 매번 새 object를 만드는 특별한 key 값이지, 접근 권한을 자동으로 제한하는 flag가 아닙니다. 접근 mode는 별도의 0600입니다. 고정 key로 rendezvous를 구현한다면 IPC_CREAT만으로 기존 segment를 새것처럼 초기화하지 않도록 주의해야 합니다. 충돌을 거절할 목적이라면 IPC_EXCL을 함께 검토해야 합니다. shmget(2)
이 예제에서는 고정 key 대신 작은 UNIX domain socket으로 완전히 초기화된 segment의 ID를 전달합니다. Socket은 object discovery와 마지막 종료 확인만 담당하고, 요청값과 결과값은 shared memory로 이동합니다. 추가 socket을 사용한 이유는 "segment를 찾았다"와 "그 안의 semaphore를 이제 사용해도 된다"를 구별하기 위해서입니다.
실습은 같은 user가 소유한 private directory 안에서, 신뢰하는 process 두 개만 실행하는 조건입니다. 서로 같은 IPC namespace에 있어야 합니다. 0600과 directory 권한은 같은 user의 악의적인 process까지 격리하지 못합니다. 다중 client, 인증된 service, 서로 다른 ABI 사이의 wire protocol을 구현한 코드는 아닙니다.
Semaphore 세 개로 data의 소유권을 넘깁니다
Shared structure에는 process-local pointer가 없습니다. sem_t 세 개와 uint32_t 값만 둡니다. sem_t는 file format이 아니므로 크기가 같다는 검사만으로 다른 libc와의 호환성이 생기지는 않습니다.
| 단계 | Client | Server |
|---|---|---|
| 준비 | 아직 segment를 사용하지 않습니다 | 세 semaphore를 초기화한 뒤 ID를 전달합니다 |
| request | value에 21을 쓰고 request를 post합니다 | request wait가 성공한 뒤 21을 읽습니다 |
| reply | reply를 wait합니다 | value를 42로 바꾸고 reply를 post합니다 |
| 확인 | 42를 검사한 뒤 done을 post합니다 | done을 wait합니다 |
| 종료 | detach한 다음 control socket을 닫습니다 | socket EOF까지 확인한 뒤 semaphore를 destroy합니다 |
sem_init(..., 1, 0)의 두 번째 인자를 1로 지정하고, semaphore 자체를 shared segment에 둡니다. Process-local memory에 둔 semaphore로 다른 process를 깨우는 구조가 아닙니다. sem_init(3)
다음 exchange.h가 request/reply 전체를 담당합니다. sem_timedwait()는 같은 absolute deadline으로 EINTR를 재시도하므로 signal을 받을 때마다 15초를 새로 부여하지 않습니다. 다만 이 API의 deadline은 CLOCK_REALTIME에 기반하므로 wall clock 조정의 영향은 남습니다. sem_wait(3)
#ifndef LAB_EXCHANGE_H
#define LAB_EXCHANGE_H
#include <errno.h>
#include <semaphore.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>
struct exchange {
sem_t request;
sem_t reply;
sem_t done;
uint32_t value;
};
static inline int exchange_init(struct exchange *box)
{
box->value = 0;
if (sem_init(&box->request, 1, 0) == -1)
return -1;
if (sem_init(&box->reply, 1, 0) == -1) {
int saved = errno;
sem_destroy(&box->request);
errno = saved;
return -1;
}
if (sem_init(&box->done, 1, 0) == -1) {
int saved = errno;
sem_destroy(&box->reply);
sem_destroy(&box->request);
errno = saved;
return -1;
}
return 0;
}
static inline int exchange_wait(sem_t *sem)
{
struct timespec deadline;
if (clock_gettime(CLOCK_REALTIME, &deadline) == -1)
return -1;
deadline.tv_sec += 15;
int result;
do {
result = sem_timedwait(sem, &deadline);
} while (result == -1 && errno == EINTR);
return result;
}
static inline int exchange_server(struct exchange *box)
{
if (exchange_wait(&box->request) == -1)
return -1;
if (box->value != 21) {
errno = EPROTO;
return -1;
}
box->value *= 2;
if (sem_post(&box->reply) == -1)
return -1;
return exchange_wait(&box->done);
}
static inline int exchange_client(struct exchange *box)
{
box->value = 21;
if (sem_post(&box->request) == -1 ||
exchange_wait(&box->reply) == -1)
return -1;
if (box->value != 42) {
errno = EPROTO;
return -1;
}
puts("reply=42");
return sem_post(&box->done);
}
static inline int exchange_destroy(struct exchange *box)
{
int result = 0;
if (sem_destroy(&box->done) == -1)
result = -1;
if (sem_destroy(&box->reply) == -1)
result = -1;
if (sem_destroy(&box->request) == -1)
result = -1;
return result;
}
#endif
done 하나만 보고 바로 sem_destroy()하지 않는 점도 중요합니다. Client가 마지막 sem_post()를 마친 뒤 shared structure를 더 이상 사용하지 않는다는 확인이 별도로 필요하기 때문입니다. 이 protocol에서는 client가 cleanup의 마지막에 socket을 닫고, server가 그 EOF를 확인합니다. 이 순서는 협력적인 client의 계약이지 악의적인 writer에 대한 강제 수단은 아닙니다.
Control connection도 완전한 코드를 사용합니다
channel.h는 한 번의 연결과 작은 record 전달을 다룹니다. SOCK_SEQPACKET을 사용해 message boundary를 유지하고, 예상 길이와 다른 record는 거절합니다. Server는 기존 pathname을 먼저 지우지 않습니다. 이미 사용 중인 socket이면 실패해야 기존 owner의 연결을 보존할 수 있기 때문입니다. unix(7)
#ifndef LAB_CHANNEL_H
#define LAB_CHANNEL_H
#include <errno.h>
#include <poll.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <time.h>
#include <unistd.h>
static inline int channel_wait(int fd)
{
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1)
return -1;
long long end = (long long)now.tv_sec * 1000 + now.tv_nsec / 1000000 + 15000;
for (;;) {
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1)
return -1;
long long left = end - ((long long)now.tv_sec * 1000 + now.tv_nsec / 1000000);
if (left <= 0) {
errno = ETIMEDOUT;
return -1;
}
struct pollfd event = {.fd = fd, .events = POLLIN};
int result = poll(&event, 1, (int)left);
if (result > 0)
return 0;
if (result == -1 && errno != EINTR)
return -1;
}
}
static inline int channel_open(const char *path, int server)
{
struct sockaddr_un address = {.sun_family = AF_UNIX};
if (strlen(path) >= sizeof(address.sun_path)) {
errno = ENAMETOOLONG;
return -1;
}
memcpy(address.sun_path, path, strlen(path) + 1);
int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
if (fd == -1)
return -1;
int bound = 0;
if (server) {
if (bind(fd, (struct sockaddr *)&address, sizeof(address)) == -1)
goto fail;
bound = 1;
if (listen(fd, 1) == -1)
goto fail;
printf("READY %s\n", path);
fflush(stdout);
} else if (connect(fd, (struct sockaddr *)&address, sizeof(address)) == -1) {
goto fail;
}
return fd;
fail:;
int saved = errno;
close(fd);
if (bound)
unlink(path);
errno = saved;
return -1;
}
static inline int channel_accept(int listener)
{
if (channel_wait(listener) == -1)
return -1;
return accept4(listener, NULL, NULL, SOCK_CLOEXEC);
}
static inline int channel_send(int fd, const void *data, size_t length)
{
ssize_t count;
do {
count = send(fd, data, length, MSG_NOSIGNAL);
} while (count == -1 && errno == EINTR);
if (count == (ssize_t)length)
return 0;
if (count >= 0)
errno = EPROTO;
return -1;
}
static inline int channel_receive(int fd, void *data, size_t length)
{
if (channel_wait(fd) == -1)
return -1;
ssize_t count = recv(fd, data, length, MSG_TRUNC | MSG_DONTWAIT);
if (count == (ssize_t)length)
return 0;
if (count >= 0)
errno = EPROTO;
return -1;
}
static inline int channel_finished(int fd)
{
if (channel_wait(fd) == -1)
return -1;
char byte;
ssize_t count = recv(fd, &byte, 1, MSG_DONTWAIT);
if (count == 0)
return 0;
if (count > 0)
errno = EPROTO;
return -1;
}
static inline int channel_remove(int listener, const char *path)
{
int result = 0;
if (close(listener) == -1)
result = -1;
if (unlink(path) == -1)
result = -1;
return result;
}
#endif
대기에는 poll()과 monotonic deadline을 사용합니다. 이 제한과 shared semaphore의 15초 제한은 각 대기 단계의 timeout이며, 프로그램 전체가 항상 15초 안에 끝난다는 뜻은 아닙니다. 짧은 record를 한 번 보내는 협력적 single-client 실습으로 범위를 제한했습니다.
Server는 생성한 segment의 제거까지 맡습니다
shmat()의 실패값은 MAP_FAILED라고 추측하지 않고 API가 정한 (void *)-1과 비교합니다. Client도 NULL 위치를 요청하므로 두 process의 virtual address는 달라도 됩니다. shmat(2)
sysv_server.c의 전체 코드입니다.
#define _GNU_SOURCE
#include "channel.h"
#include "exchange.h"
#include <stdlib.h>
#include <sys/shm.h>
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s SOCKET\n", argv[0]);
return EXIT_FAILURE;
}
int result = EXIT_FAILURE, listener = -1, peer = -1;
int initialized = 0, published = 0;
int id = shmget(IPC_PRIVATE, sizeof(struct exchange), IPC_CREAT | 0600);
struct exchange *box = (void *)-1;
if (id == -1)
goto out;
printf("shmid=%d\n", id);
fflush(stdout);
box = shmat(id, NULL, 0);
if (box == (void *)-1 || exchange_init(box) == -1)
goto out;
initialized = 1;
listener = channel_open(argv[1], 1);
if (listener == -1)
goto out;
peer = channel_accept(listener);
if (peer == -1)
goto out;
published = 1;
if (channel_send(peer, &id, sizeof(id)) == -1 ||
exchange_server(box) == -1 || channel_finished(peer) == -1)
goto out;
puts("exchange complete");
result = EXIT_SUCCESS;
out:
if (result != EXIT_SUCCESS)
perror("System V server");
if (initialized && (!published || result == EXIT_SUCCESS) &&
exchange_destroy(box) == -1) {
perror("sem_destroy");
result = EXIT_FAILURE;
}
if (box != (void *)-1 && shmdt(box) == -1) {
perror("shmdt");
result = EXIT_FAILURE;
}
if (id != -1 && shmctl(id, IPC_RMID, NULL) == -1) {
perror("IPC_RMID");
result = EXIT_FAILURE;
}
if (peer != -1 && close(peer) == -1) {
perror("peer close");
result = EXIT_FAILURE;
}
if (listener != -1) {
if (channel_remove(listener, argv[1]) == -1) {
perror("listener cleanup");
result = EXIT_FAILURE;
}
}
return result;
}
sysv_client.c는 전달받은 ID의 segment 크기와 owner를 확인한 뒤 attach합니다. Server만 segment를 생성하고 제거합니다.
#define _GNU_SOURCE
#include "channel.h"
#include "exchange.h"
#include <stdlib.h>
#include <sys/shm.h>
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s SOCKET\n", argv[0]);
return EXIT_FAILURE;
}
int result = EXIT_FAILURE, id;
int peer = channel_open(argv[1], 0);
struct exchange *box = (void *)-1;
struct shmid_ds info;
if (peer == -1 || channel_receive(peer, &id, sizeof(id)) == -1)
goto out;
if (shmctl(id, IPC_STAT, &info) == -1)
goto out;
if (info.shm_segsz != sizeof(*box) || info.shm_perm.uid != geteuid()) {
errno = EPROTO;
goto out;
}
box = shmat(id, NULL, 0);
if (box == (void *)-1 || exchange_client(box) == -1)
goto out;
result = EXIT_SUCCESS;
out:
if (result != EXIT_SUCCESS)
perror("System V client");
if (box != (void *)-1 && shmdt(box) == -1) {
perror("shmdt");
result = EXIT_FAILURE;
}
/* Closing the control connection is the final quiescence handshake. */
if (peer != -1 && close(peer) == -1) {
perror("peer close");
result = EXIT_FAILURE;
}
return result;
}
published는 공유 정보를 전달하려 한 시점부터 설정합니다. 이후 protocol이 실패했다면 client가 semaphore에서 기다리고 있을 가능성이 있으므로 sem_destroy()를 강행하지 않습니다. 반면 초기화가 끝났지만 아직 공개하지 않은 object, 또는 정상 handshake까지 완료한 object는 destroy할 수 있습니다. Waiter가 남은 semaphore를 destroy하는 것은 허용되지 않습니다. sem_destroy(3)
실패 시에도 server는 자기 attachment를 detach하고 IPC_RMID를 요청합니다. 다른 attachment가 남아 있다면 segment는 마지막 detach까지 유지될 수 있습니다. Client는 자체 timeout 후 detach하고 종료합니다. 이 Linux/glibc 한정 abort 경로는 object 전체를 폐기하는 방식이며, 모든 POSIX implementation의 semaphore 자원 관리로 일반화하지 않습니다. shmctl(2)
실행하면서 확인해 보겠습니다
네 파일 exchange.h, channel.h, sysv_server.c, sysv_client.c를 같은 labs directory에 두고 compile합니다. Header도 위에 전체를 실었습니다.
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -pthread sysv_server.c -o sysv_server
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -pthread sysv_client.c -o sysv_client
run_dir=$(mktemp -d ./sysv-run-XXXXXX)
./sysv_server "$run_dir/control.sock" &
server=$!
READY가 출력된 것을 확인한 뒤 같은 shell에서 다음 부분을 실행합니다. sleep()으로 초기화 시간을 추측하지 않습니다. Server는 준비 후 15초 동안 연결을 기다립니다.
./sysv_client "$run_dir/control.sock"
wait "$server"
rmdir "$run_dir"
Client에서는 reply=42, server에서는 exchange complete가 나와야 합니다. 출력 순서 자체를 synchronization으로 사용하지는 않습니다. 실제 data 순서는 semaphore가 정합니다.
Client를 아예 시작하지 않으면 server는 timeout으로 실패하고 segment와 socket을 정리합니다. 단, SIGKILL이나 machine 장애로 cleanup 코드를 실행하지 못하면 segment가 남을 수 있습니다. Server가 처음 출력한 shmid를 기록해 두고 ipcs -m으로 owner와 해당 ID를 확인한 뒤 그 segment만 ipcrm -m의 대상으로 삼으셔야 합니다. shmdt()와 IPC_RMID가 같은 operation이 아니라는 점이 여기서 중요해집니다.
확인한 범위
2026-11-03, x86-64 WSL2 Linux 6.18.33.2-microsoft-standard-WSL2, GCC 15.2.0, glibc 2.43에서 엄격한 compiler 경고 설정으로 build했습니다. 독립 process 간 정상 왕복 여덟 번, client 선실행, client 부재 timeout, socket 이름 충돌, 잘못된 segment ID 거절을 확인했습니다. 정상·timeout·bind 실패 뒤 생성한 segment와 socket이 제거됐고, 충돌 상대의 기존 socket은 보존됐습니다.
SIGKILL 복구, 여러 client, semaphore 대기 중 clock 변경과 모든 allocation 실패 주입은 미검증입니다. 이 예제는 restart 가능한 production service가 아니라, 생성·공개·요청·응답·폐기의 경계를 한 번씩 통과하는 학습용 protocol입니다.
참고 자료
- Lorenzo Stoakes, The Linux Memory Manager, Early Access edition, 2025, §14.7.1. 위 코드는 독립 예제입니다.
- Linux man-pages 6.19,
shmget(2),shmat(2),shmctl(2),sem_init(3),sem_wait(3),sem_destroy(3),unix(7). 2026-11-03 열람.