UNIX에서 표준 입출력(STDIO)과 파일 디스크립터, fork/dup2, PTS/PTM, 파이프·리다이렉션으로 IPC를 구성하는 방법을 커널 관점과 Zig 코드 예제로 깊이 있게 다룹니다. 실전 팁과 흔한 함정, 도구 통합 사례까지 포함합니다.
때때로 UNIX 시스템에서 실행되는 복잡한 코드베이스를 다루다 보면 서로 다른 프로세스끼리 통신해야 할 때가 있습니다. 이를 넓게 부르는 말이 프로세스 간 통신(IPC)이며, 달라 보이지만 유사한 여러 방식이 존재합니다. 그중 가장 기초적인 방법이 표준 입출력(stdio)을 이용해 통신을 설정하는 것입니다. 이 글에서는 이 접근법을 깊게 파고들어 그 실체를 풀어보려 합니다.
실용성을 높이기 위해 글 말미에 몇 가지 코드 스니펫과 팁도 제공합니다. 추가로, 개념 설명을 위해 Zig 코드 스니펫을 사용했기 때문에 Zig가 어떤 언어인지, 어떻게 다루는지에 관심이 있었다면 이 글이 도움이 될 것입니다.
만약 UNIX를 소재로 한 뮤지컬이 만들어진다면, 간결하고 강렬한 한 단어 제목의 전통을 따라 아마도 "Files!"라 불렸을 겁니다. UNIX에는 모든 것이 파일이라는 오래된 격언이 있고, 이는 stdio에도 적용됩니다. 다만 세 개의 stdio 파일은 일반 파일이 아니라 장치 파일이라는 점만 다를 뿐입니다! 이에 대해서는 곧 살펴보겠습니다.
Linux에서 모든 프로세스는 files_struct라는 자료구조를 갖고 있으며, 그 안의 fdtable이 해당 프로세스에 연결된 모든 파일 디스크립터에 대한 저수준 인터페이스를 제공합니다.
/*
* The caller must ensure that fd table isn't shared or hold rcu or file lock
*/
static inline struct file *files_lookup_fd_raw(
struct files_struct *files,
unsigned int fd
) {
struct fdtable *fdt =
rcu_dereference_raw(files->fdt);
unsigned long mask =
array_index_mask_nospec(fd, fdt->max_fds);
struct file *needs_masking;
/*
* 'mask' is zero for an out-of-bounds fd,
* all ones for ok.
* 'fd&mask' is 'fd' for ok,
* or 0 for out of bounds.
*
* Accessing fdt->fd[0] is ok,
* but needs masking of the result.
*/
needs_masking =
rcu_dereference_raw(fdt->fd[fd&mask]);
return
(struct file *)
(mask &
(unsigned long)needs_masking
);
}
저수준 파일 디스크립터 조회. include/linux/fdtable.h, 커널 v6.8.2.
여기에는 열린 파일만이 아니라 모든 파일의 디스크립터가 저장됩니다. 커널 루틴은 주어진 파일 디스크립터 테이블에 대해 fd_is_open(unsigned int fd, const struct fdtable *fdt)를 호출하여 파일이 열려 있는지를 확인할 수 있습니다.
힌트! Linux 커널에서 식별자를 쉽게 찾아보고 상호 참조하고 싶다면 Bootlin 크로스 레퍼런서를 활용해 보세요: https://elixir.bootlin.com/linux/v6.8.2/source

프로세스는 일반적인 포크 루틴인 sys_clone()으로 복제됩니다. 이는 kernel_clone()을 감싼 매크로이며, 결국 가장 복잡한 copy_process() 안에서 부모 프로세스의 모든 파일 디스크립터를 복사합니다. 이는 트레이서 설정 이후에 이루어지며, 디스크립터가 복사된 다음에야 새로 포크된 프로세스에 대한 정보가 스케줄러에 전달됩니다.
files_struct를 복사하는 함수는 copy_files()이며, sched/task.h에 정의된 kernel_clone_args의 clone 인자 중 no_files가 설정된 경우를 제외하고는 이름 그대로 동작합니다.
copy_files()의 의미론을 설명하기 위해, 다음 Zig 코드를 살펴봅시다.
const std = @import("std");
pub fn main() !void {
const message = "Hello, world!\n";
// Write and create
const flags = std.os.O.WRONLY | std.os.O.CREAT;
const fd = try std.os.open("./output.txt", flags, 0o644);
const pid = try std.os.fork();
if (pid == 0) {
std.os.nanosleep(0, 100_000_000);
std.debug.print("[CHILD] Attempting to write to fd.\n", .{});
try fds("CHILD");
// This write will happen because file descriptors are duplicated independently
// even though we shall close the file descriptor corresponding to `output.txt`
// shall be closed by the parent process immediately after forking.
//
// A system call similar to `dup2()` is used.
//
// `dup2()` makes susre that the integer associated with the file descriptor
// is preserved after dupplication.
// For stdio, it ensures that STDIN shall be 0, STDOUT -- 1, etc.
const result = try std.os.write(fd, message);
if (result != message.len) {
std.debug.print("[CHILD] Failed to write to fd.\n", .{});
std.os.exit(1);
} else {
std.debug.print("[CHILD] We do what we must because we can.\n", .{});
std.os.exit(0);
}
} else {
std.debug.print("[PARENT] Closing fd immediately.\n", .{});
std.os.close(fd);
try fds("PARENT");
// Wait for child process to exit
const wpr = std.os.waitpid(pid, 0x00000000);
// Check if the child exited with an error due to closed STDIN
if (std.os.W.IFEXITED(wpr.status) and std.os.W.EXITSTATUS(wpr.status) == 0) {
std.debug.print("[PARENT] This was a triumph.\n", .{});
}
}
}
// Dummy function to print active file descriptors, ignore for the time being
pub fn fds(_: [*:0]const u8) !void {}
여기서는 프로세스를 fork한 뒤, 자식 프로세스에서 100밀리초 동안 대기시키는 동안 부모가 output.txt를 닫습니다. 앞서 설명했듯이 파일은 fork 시점에 자식으로 복사되므로, 부모가 파일을 닫았더라도 상관없습니다. 자식은 복사된 파일을 “살아 있는” 상태로 갖고 있으므로, fd는 자식의 files_struct에서 열린 파일로 해석됩니다.
이 점을 좀 더 분명히 보여주고 싶지만, 제 지식으로는 libc나 그 밖의 수단으로 직접 files_struct를 조회할 수는 없습니다. 대신 커널이 추적하는 파일 디스크립터 정보를 /proc/self/fd를 조회해 얻을 수 있습니다. 이전 예시의 더미 함수 fds를 구현해 봅시다.
pub fn fds(whose: [*:0]const u8) !void {
var dir = try std.fs.openIterableDirAbsolute("/proc/self/fd", .{});
defer dir.close();
var it = dir.iterate();
while (try it.next()) |entry| {
if (entry.kind == .sym_link) {
var resolved_path:
[std.fs.MAX_PATH_BYTES]u8 = undefined;
const sl_resolved_path =
try std.os.readlinkat(
dir.dir.fd,
entry.name,
&resolved_path
);
std.debug.print("[{s}] FD {s}: {s}\n", .{ whose, entry.name, sl_resolved_path });
}
}
}
깨끗한 터미널에서 실행하면, 부모 프로세스에서 output.txt가 실제로 닫혔음을 확인할 수 있는 출력이 나타납니다.
λ zig build run-01B-fd-forward && cat output.txt
[PARENT] Closing fd immediately.
[PARENT] FD 0: /dev/pts/19
[PARENT] FD 1: /dev/pts/19
[PARENT] FD 2: /dev/pts/19
[PARENT] FD 3: /proc/455/fd
[CHILD] Attempting to write to fd.
[CHILD] FD 0: /dev/pts/19
[CHILD] FD 1: /dev/pts/19
[CHILD] FD 2: /dev/pts/19
[CHILD] FD 3: /home/sweater/flake-mag/001/01/output.txt
[CHILD] FD 4: /proc/456/fd
[CHILD] We do what we must because we can.
[PARENT] This was a triumph.
Hello, world!
위 스니펫에서는 파일을 열고 닫는 표준 저수준 함수를 사용하는 방법을 보여주었습니다. 이제 같은 함수로 stdio 파일도 다룰 수 있음을 보이겠습니다. 또한 stdio 파일이라 하더라도 닫혀 있으면, Linux 커널이 포크된 프로세스로 복사하지 않는다는 점도 확인해봅시다.
pub fn main() !void {
try fds("PARENT_BEFORE");
// Close standard file descriptors
std.os.close(0);
try fds("PARENT_NO_STDIN");
std.os.close(1);
try fds("PARENT_NO_STDOUT");
// std.os.close(2); // Leave STDERR open for debugging
const pid = try std.os.fork();
if (pid == 0) {
try fds("CHILD");
const stdin_fd = 0; // STDIN file descriptor
var buf = [_:1]u8{0};
std.debug.print("[CHILD] Attempting to read from STDIN...\n", .{});
_ = std.os.read(stdin_fd, &buf) catch std.os.exit(1);
std.debug.print("[CHILD] Read from STDIN: {s}\n", .{buf});
std.os.exit(0);
} else {
try fds("PARENT_AFTER");
const wpr = std.os.waitpid(pid, 0x00000000); // Wait for child process to exit
// Check if the child exited with an error due to closed STDIN
if (std.os.W.IFEXITED(wpr.status) and std.os.W.EXITSTATUS(wpr.status) == 1) {
std.debug.print("[PARENT] Child process confirmed that STDIN is closed.\n", .{});
} else {
std.debug.print("[PARENT] Child process did not behave as expected.\n", .{});
}
}
}
이 코드는 앞선 예시와 동일한 fds 함수를 사용합니다.
이 명령의 출력을 보면, fds 함수에서 디렉터리를 열었기 때문에 파일 디스크립터 0이 /proc/26202/fd에 할당된 것을 볼 수 있습니다. 사용 가능한 첫 번째 파일 디스크립터가 사용되는데, 이 경우 STDIN을 닫았기 때문에 0이 선택되었습니다. 만약 stdio 파일을 닫는 것이 좋은 생각인지 궁금했다면, 이 기묘한 부작용만으로도 좋은 생각이 아님을 충분히 납득하실 겁니다.
λ zig build run-01A-closed-stdio
[PARENT_BEFORE] FD 0: /dev/pts/19
[PARENT_BEFORE] FD 1: /dev/pts/19
[PARENT_BEFORE] FD 2: /dev/pts/19
[PARENT_BEFORE] FD 3: /proc/26202/fd
[PARENT_NO_STDIN] FD 0: /proc/26202/fd
[PARENT_NO_STDIN] FD 1: /dev/pts/19
[PARENT_NO_STDIN] FD 2: /dev/pts/19
[PARENT_NO_STDOUT] FD 0: /proc/26202/fd
[PARENT_NO_STDOUT] FD 2: /dev/pts/19
[PARENT_AFTER] FD 0: /proc/26202/fd
[PARENT_AFTER] FD 2: /dev/pts/19
[CHILD] FD 0: /proc/26203/fd
[CHILD] FD 2: /dev/pts/19
[CHILD] Attempting to read from STDIN...
[PARENT] Child process confirmed that STDIN is closed.
부모와 자식 모두에서 파일 디스크립터 0, 1, 2가 설정되어 있음을 볼 수 있습니다. 물론 온라인의 대부분(혹은 전부)의 stdio 자료에서는 이 세 파일이 부모에서 자식으로 전달되는 특별한 파일이라고 설명합니다. 하지만 프로세스 초기화 코드나 앞서 언급한 copy_files 함수의 동작을 살펴보면, files_struct에 있는 파일에 대해 어떤 특별 대우도 없다는 사실을 확인할 수 있습니다!
static int copy_files(
unsigned long clone_flags,
struct task_struct *tsk,
int no_files
) {
struct files_struct *oldf, *newf;
int error = 0;
/*
* A background process may not have any files ...
*/
oldf = current->files;
if (!oldf)
goto out;
if (no_files) {
tsk->files = NULL;
goto out;
}
if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count);
goto out;
}
newf = dup_fd(oldf, NR_OPEN_MAX, &error);
if (!newf)
goto out;
tsk->files = newf;
error = 0;
out:
return error;
}
커널에서 파일을 복사하는 루틴은 stdio에 대한 특별 대우가 없다. kernel/fork.c, 커널 6.8.2.
실제로 파일/프로세스를 담당하는 커널 코드 전체를 stdio 관련 내용으로 샅샅이 훑어봐도, 아무것도 찾을 수 없습니다.
다음 섹션에서는 stdio 파일이 실제로 어떻게 생성되는지를 살펴보겠습니다.

찾고 찾던 stdio 파일이 가장 먼저 모습을 드러내는 곳은 console_on_rootfs() 함수입니다. 커널이 언패킹되는 과정(head.S -> start_kernel -> ...)에서 initramfs가 /dev/console 파일을 만듭니다. 이후 이 파일이 세 개의 파일 디스크립터로 멀티플렉싱되고, 초기 콘솔 드라이버가 이를 사용해 stdio를 구성합니다.
/* Open /dev/console, for stdin/stdout/stderr, this should never fail */
void __init console_on_rootfs(void)
{
struct file *file = filp_open("/dev/console", O_RDWR, 0);
if (IS_ERR(file)) {
pr_err("Warning: unable to open an initial console.\n");
return;
}
init_dup(file);
init_dup(file);
init_dup(file);
fput(file);
}
Linux 부팅 과정에서 stdio가 처음 등장하는 곳은 초기 콘솔. init/main.c, 커널 6.8.2.
참고! 일반적으로 stdio는
dup2()시스템 콜로 설정되지만, 초기 콘솔 설정은 다른 방식을 씁니다. 경쟁 상태를 막기 위해 커스텀 파일 디스크립터 복제 기법을 쓰며, 내부적으로는 RCU(Read-Copy-Update) 동기화를 활용합니다. 요점은 다음과 같습니다:
- 초기 파일 디스크립터 테이블에 대한 포인터를 제거하여 새로운 읽기 시도를 막습니다.
- 기존 포인터 뒤의 데이터를 사용 중인 리더들이 임계 구역을 마칠 때까지 기다립니다.
- 모든 리더가 임계 구역 종료를 보고하면 해당 메모리 구역을 해제(또는 재배치)합니다.
일상에서 볼 수 있는 RCU 전략의 예로는 Firefox가 업데이트 후 재시작을 강제하는 방식이 있습니다. 기존 탭에서 하던 작업은 마치게 하지만, 새 탭을 열려 하면 재시작을 요구합니다.
지금까지의 코드 스니펫을 보면, 다양한 프로그램의 표준 입출력이 서로 섞이지 않는 이유를 감으로 이해하셨을 겁니다. 이제 좀 더 정밀하게 이야기해 봅시다.
우선, 앞서 본 /dev/console이나 우리가 봤던 /dev/pts/19는 텍스트 파일과 같은 일반 파일이 아니라는 점을 분명히 해야 합니다.
참고!
pts의pt는 "pseudo-terminal"(의사 터미널)을,s는 옛 용어로 "secondary"를 의미합니다. 의사 터미널은 가상 터미널과 혼동하면 안 됩니다! 가상 터미널은 하드웨어 터미널을 소프트웨어로 에뮬레이션한 것입니다.wayland나X11같은 그래픽 서버가 없을 때 Linux는 여러 콘솔을 만들고, 이들은/dev/tty{0,1,...}장치들을 통해 각자의 가상 터미널에 연결됩니다. PT-메인과 PT-세컨더리(커널에서 이름은 다르지만)는libc의openpty()호출로 확보됩니다. 가상 터미널은 커널의getty()호출로 생성됩니다.

Linux 커널은 다양한 파일 종류를 정의하여, 서두에서 말한 "모든 것이 파일"이라는 격언이 현실보다 단순함을 잘 보여줍니다. 파일 종류는 다음과 같습니다.
|로 만들지만 mkfifo로 이름 있는 파이프를 만들 수 있습니다.위 설명만 보고도 /dev/pts에 어떤 종류의 파일이 있을지 짐작하실 수 있겠지만, 직접 확인해 봅시다.
const std = @import("std");
pub fn print_kinds(from: []const u8) !void {
var dir = try std.fs.openIterableDirAbsolute(from, .{});
defer dir.close();
var it = dir.iterate();
while (try it.next()) |entry| {
// Switch on entry.kind
switch (entry.kind) {
.block_device => std.debug.print("block device: {s}\n", .{entry.name}),
.character_device => std.debug.print("character device: {s}\n", .{entry.name}),
.directory => std.debug.print("directory: {s}\n", .{entry.name}),
.named_pipe => std.debug.print("named pipe: {s}\n", .{entry.name}),
.sym_link => std.debug.print("sym link: {s}\n", .{entry.name}),
.file => std.debug.print("file: {s}\n", .{entry.name}),
.unknown => std.debug.print("unknown: {s}\n", .{entry.name}),
else => std.debug.print("non-linux: {s}\n", .{entry.name}),
}
}
}
pub fn main() !void {
try print_kinds("/dev/pts");
}
주어진 디렉터리 안의 파일 종류를 출력하는 Zig 프로그램.
출력은 다음과 같습니다.
character device: 0
character device: 2
character device: 1
character device: ptmx
try print_kinds("/dev/pts")의 출력 예.
뜻밖에도 ptmx라는 문자 장치를 발견했습니다. 이 글에서는 내부 동작까지 깊게 다루진 않겠지만, 그 개념은 설명하겠습니다. 루트 소유의 ptmx(pseudo-terminal multiplexer, 의사 터미널 멀티플렉서)는 의사 터미널 장치가 만들어지는 핵심입니다.
crw--w---- 1 sweater tty 136, 0 Apr 4 21:46 0
crw--w---- 1 sweater tty 136, 1 Apr 1 03:38 1
crw--w---- 1 sweater tty 136, 2 Apr 1 03:38 2
c--------- 1 root root 5, 2 Mar 31 06:32 ptmx
ls -la /dev/pts의 출력.
터미널 에뮬레이터가 동작하려면 PTM 장치와 PTS 장치가 필요합니다. 메인 의사 터미널 장치(PTM)는 경로가 없으며, I/O 연산을 통해 터미널 상태를 조율하는 데 사용됩니다. 또한 세션 관리 오케스트레이션을 전담합니다.
세컨더리 의사 터미널 장치(PTS)는 터미널 에뮬레이터의 프런트엔드에 직접 연결된 파일입니다. 여기에 쓰기(write)를 하면 입력이 PTM으로 전달되고, 일반적으로 화면에 표시됩니다.
한때는 Linux 사용자들이 임의로 PTM/PTS 쌍을 만들 수 있었습니다. 하지만 /dev에 떠다니는 객체 수가 늘어나면서 PTM/PTS 쌍을 발급하는 작업을 중앙집중화할 필요가 생겼고, 그 결과 ptmx가 탄생했습니다. 시스템 콜은 ptmx에 I/O를 수행해 커널이 PTM/PTS 쌍을 만들고, 그 쌍을 프로세스에서 사용하도록 반환받습니다.
PTS는 실제 데이터를 저장하지 않는 단순 I/O 오케스트레이션 도구이기 때문에 다음과 같은 성질이 있습니다.
다음 실험을 해볼 수 있습니다.
tty를 실행해 사용하는 의사 터미널의 PTS 번호를 알아냅니다.cat /dev/pts/$pts_id로 그 PTS를 읽기 시작합니다.echo Hello를 입력해 봅니다.관찰되는 현상은 키 입력이 cat에 소비되거나, 터미널 에뮬레이터에 표시되거나 둘 중 하나라는 것입니다. 키 입력이 PTM까지 도달하여 PTS를 통해 화면에 그려지기 때문이지, 동시에 양쪽으로 흘러가지 않습니다. 장난을 좋아하는 분께는 재미있을지 모르지만, 활성 콘솔의 동작은 꽤 헷갈립니다.

여러분이 선호하는 터미널 에뮬레이터의 코드를 보면, openpty(&main, &secondary, ...)로 PTM/PTS 쌍을 얻습니다. 이후 그 쌍의 PTS 구성 요소가 dup2로 정확히 복제되어 STDIN, STDOUT, STDERR 모두를 담당하는 바로 그 stdio 파일이 됩니다.
int
ttynew(const char *line, char *cmd, const char *out, char **args)
{
int m, s;
if (out) {
term.mode |= MODE_PRINT;
iofd = (!strcmp(out, "-")) ?
1 : open(out, O_WRONLY | O_CREAT, 0666);
if (iofd < 0) {
fprintf(stderr, "Error opening %s:%s\n",
out, strerror(errno));
}
}
if (line) {
if ((cmdfd = open(line, O_RDWR)) < 0)
die("open line '%s' failed: %s\n",
line, strerror(errno));
dup2(cmdfd, 0);
stty(args);
return cmdfd;
}
/* seems to work fine on linux, openbsd and freebsd */
if (openpty(&m, &s, NULL, NULL, NULL) < 0)
die("openpty failed: %s\n", strerror(errno));
switch (pid = fork()) {
case -1:
die("fork failed: %s\n", strerror(errno));
break;
case 0:
close(iofd);
close(m);
setsid(); /* create a new process group */
/**********/
dup2(s, 0);
dup2(s, 1);
dup2(s, 2);
/**********/
if (ioctl(s, TIOCSCTTY, NULL) < 0)
die("ioctl TIOCSCTTY failed: %s\n", strerror(errno));
if (s > 2)
close(s);
#ifdef __OpenBSD__
if (pledge("stdio getpw proc exec", NULL) == -1)
die("pledge\n");
#endif
execsh(cmd, args);
break;
default:
#ifdef __OpenBSD__
if (pledge("stdio rpath tty proc", NULL) == -1)
die("pledge\n");
#endif
close(s);
cmdfd = m;
signal(SIGCHLD, sigchld);
break;
}
return cmdfd;
}
suckless terminal의 stdio. st.c, st 0.9.
이미 살펴보았듯이, stdio의 각 엔티티를 위해 서로 다른 세 장치 파일이 만들어지는 것은 아닙니다. 그렇다면 같은 파일인데도 데이터가 흘러들어갈 때 어떻게 구분될까요? 짧은 답은, 구분되지 않는다는 것입니다. 심지어 STDIN은 기본적으로 쓰기 권한으로 열리므로, 그 자체에 write할 수도 있습니다. 실제로 STDIN에 쓰면 STDOUT이나 STDERR에 쓰는 것과 동일하게 터미널이 반응합니다. 콘솔 입장에서 보면 이 세 파일은 진짜로 도플갱어입니다.
const std = @import("std");
pub fn main() !void {
_ = try std.os.write(0, "Hello, world!\n");
}
이 프로그램은 터미널 에뮬레이터에 "Hello, world!"를 출력합니다.
다만 STDIN이 PTS에 연결된 의미는 사용자 입력을 읽는 것입니다. 키 입력 처리를 통해 PTM을 거쳐 PTS로 전달된 입력을 읽는 것이죠. 따라서 위 write는 다음 예시 프로그램이 읽지 못하며, 자식 프로세스는 사용자가 키를 눌러 STDIN을 통해 전달될 때까지 종료하지 않을 것입니다.
const std = @import("std");
pub fn main() !void {
const pid = try std.os.fork();
if (pid == 0) {
var buf = [_:255]u8{0};
_ = try std.os.read(0, &buf);
std.debug.print("Child read: {s}\n", .{buf});
} else {
std.os.nanosleep(0, 10_000);
_ = try std.os.write(0, "Hello, world!\n");
}
}
이 프로그램은 파이프를 통해 무언가를 주입하면 크래시합니다. 파이프는 쓰기 모드로 열리지 않기 때문입니다!
따라서 stdio의 "도플갱어 상태"를 풀 수 있는 유일한 방법은 일부 파일 디스크립터를 다른 것으로 교체하는 것입니다. 가장 흔한 방법은 bash 같은 셸에서 파이프와 리다이렉션을 사용하는 것이죠. 다음 스니펫에서는 각 stdio 파일이 교체되는 모습을 확인할 수 있습니다.
λ ls -la /proc/self/fd <input.txt 2>output.err | tee output.txt
total 0
dr-x------ 2 sweater sweater 0 Apr 7 19:26 ./
dr-xr-xr-x 9 sweater sweater 0 Apr 7 19:26 ../
lr-x------ 1 sweater sweater 64 Apr 7 19:26 0 -> /tmp/input.txt
l-wx------ 1 sweater sweater 64 Apr 7 19:26 1 -> pipe:[3717804]
l-wx------ 1 sweater sweater 64 Apr 7 19:26 2 -> /tmp/output.err
lr-x------ 1 sweater sweater 64 Apr 7 19:26 3 -> /proc/19836/fd
입력은 파일, 출력은 파이프로, stderr는 일반 파일에 기록. bash 5.0.7.
참고!
tee는 대화형 세션의 흔적을 보존하거나 이후 자동 처리를 위해 기록하고 싶을 때 유용합니다. STDIN에서 받은 것을 STDOUT으로 그대로 내보내면서 동시에 파일에 기록합니다. 덮어쓰지 않고 덧붙이려면tee -a를 사용하세요.
글의 서두에서 보았듯이, 우리는 stdio와 상호작용하는 소프트웨어를 직접 작성할 수 있습니다. 또한 마지막 예시에서 본 것처럼, 셸에서 프로세스를 실행할 때 파이프(|)와 리다이렉션(> 혹은 <)으로 PTS를 다른 파일로 바꿔치기할 수 있습니다.
이제 일반적으로 사용하는 명령들과 코드 속에서, stdio를 IPC에 활용하는 몇 가지 예를 살펴보겠습니다.
Linux나 다른 UNIX 시스템을 배우면 가장 먼저 익히는 명령 시퀀스 중 하나가 "뭐시기 뭐시기 파이프 grep"입니다. 하지만 자주 하는 일은 어떤 파일에서 문자열을 grep 하는 것입니다. 이를 cat file.txt | grep string으로 쓰는 것은(중요하진 않지만) 불필요한 파일 디스크립터를 더 만듭니다. 이 글에서 배운 stdio 지식을 적용하면 grep string <file.txt라고 쓰면 됩니다. 이렇게 하면 파일 디스크립터 0이 곧바로 file.txt를 가리키도록 대체됩니다.
CLI 인자를 쓸지 stdio를 쓸지 결정할 때 중요한 고려사항은 주입할 데이터의 양입니다. 단순 문자열(특히 이스케이프가 필요 없는)이라면 인자를 쓰는 것이 충분히 합리적입니다. 물론 이스케이프를 잘 하면 CLI 인자에 큰 데이터를 넣을 수도 있지만, 다음 예에서 보듯 불필요한 취약점을 초래하는 나쁜 설계입니다.
Sun Apr 07 00:27:32:244240400 sweater@conflagrate /tmp
λ export x=$(cat <<EOF
Multiline
File
Name.txt
EOF
)
Sun Apr 07 00:27:38:754602700 sweater@conflagrate /tmp
λ echo "$x"
Multiline
File
Name.txt
Sun Apr 07 00:27:41:467729600 sweater@conflagrate /tmp
λ echo "Hello World" > "$x"
Sun Apr 07 00:27:52:976430800 sweater@conflagrate /tmp
λ cat Multiline$'\n'File$'\n'Name.txt
Hello World
여러 줄 문자열을 파일 이름으로 쓰기.
참고! 위 스니펫은 bash의 here-document를 사용합니다. 이는 입력 리다이렉션의 일종으로, 임시 일반 파일을 만들고 두 개의 제한 문자열 사이 내용을 그 파일에 쓰고 STDIN으로 리다이렉션합니다. 리다이렉션이 끝나면 파일이 삭제됩니다. 예를 들어
<<를ls -la /proc/self/fd에 사용하면0 -> '/tmp/sh-thd.myfoxY (deleted)'와 비슷한 출력을 볼 수 있습니다.
또 "큰" 입력을 읽는 데 STDIN을 쓰는 이유는 유연성입니다. 여러분의 프로그램을 호출하는 도구가 출력을 특정 형식으로 꾸밀 필요가 없습니다. 예를 들어 IPC 호출 인코딩에 JSON을 사용한다면, 다음 예에서 해답을 내는 프로그램이 여러 줄 JSON, 한 줄 JSON, 심지어 다소 이상한 포맷의 JSON을 출력하더라도 통신이 깨지지 않습니다.
pub fn main() {
let arg_input = std::env::args().nth(1).expect("No input file provided");
let solution_str = std::io::stdin()
.lock()
.lines()
.next()
.expect("No solution provided")
.expect("Failed to read solution");
let solution =
Solution::new(
serde_json::from_str(&solution_str)
.expect("Failed to parse solution")
);
let input =
Input::from_json(
&PathBuf::from(arg_input)
).expect("Failed to read input file");
let forest = generate_forest(input.clone(), solution.clone());
let score = score_graph(&forest);
println!("{}", score);
}
make나 npm 같은 프로그래머블 도구에서 명령을 실행해야 한다면, 보통 도구를 여러분의 방식으로 "비틀어" 쓸 수 있습니다. 커맨드 러너는 대개 셸 내부에서 명령을 실행하므로, 레시피 안에서 cat을 활용해 STDIN을 원하는 곳으로 전달할 수 있습니다.
약간의 예고편으로, make 레시피에서 깔끔한 STDOUT을 유지하려면 레시피의 각 명령 앞에 @를 붙이세요. 여기서 STDIN을 전달하기 위해 cat 앞에 @를 붙입니다.
check: target/release/checker
@cat | ./target/release/checker $(input)
npm의 경우 다음처럼 할 수 있습니다.
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"cat": "cat | index.js"
},
STDIO로 IPC를 구성할 때는 파이프라인의 기반이 되는 프로그램들이 어떻게 fork하는지, 그리고 STDIN이 끝나면 종료하는지를 반드시 알아야 합니다.
실무에서 흔한 예는 REPL을 실행하면서 포크된 스레드에서 데이터를 처리하는 경우입니다. 저는 Elixir 같은 BEAM 언어를 사용하다가 데인 적이 있습니다. BEAM 언어에서는 모든 일이 포크된 그린 스레드에서 일어나기 때문입니다. iex(Elixir REPL)를 띄워 스폰된 프로세스에서 데이터를 받으려 기대하면 실망하게 됩니다.
좀 더 대중적인 언어로 보여주기 위해 다음을 생각해 봅시다.
import code
import threading
import time
results = {}
def background_task_do(t):
print(f"Starting background task: sleeping {t}s")
time.sleep(t)
results[t] = f"Finished after {t} seconds."
print(f"Task with t={t} done. results so far: {results}")
def background_task(t):
threading.Thread(
target=background_task_do,
args=(t,),
daemon=True
).start()
def main():
banner = """
Python 3 early exit demo. Available extra commands:
background_task(n) -> start a slow task that sleeps n seconds
results -> see which tasks have finished
Press Ctrl-D to exit the shell.
"""
local_env = {
"background_task": background_task,
"results": results,
}
code.interact(banner=banner, local=local_env)
print("\nExited the shell. Let's see if tasks completed.")
print("results =", results)
if __name__ == "__main__":
main()
이 스크립트는 Python REPL을 띄우고, 사용자에게 background_task(n) 명령으로 느린 작업을 시작하게 합니다. 이 작업은 완료되면 results 딕셔너리에 무언가를 씁니다. 실행하면 REPL이 뜨고, 작업을 시작할 수 있습니다.
❯ python3 early.py
Python 3 early exit demo. Available extra commands:
background_task(n) -> start a slow task that sleeps n seconds
results -> see which tasks have finished
Press Ctrl-D to exit the shell.
>>> background_task(5)
Starting background task: sleeping 5s
>>> background_task(3)
Starting background task: sleeping 3s
>>> background_task(1)
Starting background task: sleeping 1s
>>> Task with t=1 done. results so far: {1: 'Finished after 1 seconds.'}
Task with t=3 done. results so far: {1: 'Finished after 1 seconds.', 3: 'Finished after 3 seconds.'}
Task with t=5 done. results so far: {1: 'Finished after 1 seconds.', 3: 'Finished after 3 seconds.', 5: 'Finished after 5 seconds.'}
now exiting InteractiveConsole...
Exited the shell. Let's see if tasks completed.
results = {1: 'Finished after 1 seconds.', 3: 'Finished after 3 seconds.', 5: 'Finished after 5 seconds.'}
이제 이 스크립트를 파이프라인 일부로 실행하고 싶다고 해봅시다. 예를 들어 echo "background_task(1)" | python3 early.py로요. 답은 글 맨 마지막에!
또 다른 고전 함정은 조기 종료와 반대되는 경우입니다. STDIO로 IPC를 구성할 때, 프로그램들이 실제로 STDOUT에 데이터를 내보내고 STDIN이 끝나면 종료하는지를 알아야 합니다.
표준 Linux 유틸리티로 이를 시연해 보겠습니다.
파이프라인의 어떤 프로그램이 STDIN이 끝나도 종료하지 않으면 어떻게 될까요? 파이프라인은 해당 프로그램이 끝나기를 무한정 기다리며 멈춥니다.
더 나아가, 파이프라인을 시작하는 방식에 주의하지 않으면, 초기 프로그램이 STDOUT에 아무 것도 쓰지 않고 대기 상태에 빠져 데드락에 걸릴 수 있습니다.
tail -f - | cat
또 다운스트림 프로그램의 STDIN을 STDOUT에 연결하지 않도록 주의해야 합니다. 다음 셸 스크립트가 하는 일을 코드가 어떤 방식으로든 수행하면, 데드락에 빠질 것입니다.
mkfifo /tmp/pipeA
mkfifo /tmp/pipeB
cat /tmp/pipeA > /tmp/pipeB &
cat /tmp/pipeB > /tmp/pipeA
stdio로 IPC를 구성하는 것이 서로 다른 프로세스 간 데이터 통신을 보장하는 가장 단순한 방법이긴 하지만, 파이프라인에서 서드파티 도구를 사용할 때 위험이 없는 것은 아닙니다. 이 글의 마무리는 최근 우리 팀을 괴롭혔던 버그 사례입니다.
여기서 다룰 시스템은 여러 개의 바이너리를 다루며, 일부는 우리가 제어할 수 있고, IPC에 stdio를 사용합니다. 우리는 표준 레시피 API를 사용해 Makefile로 이 바이너리들을 빌드/실행합니다.
모든 테스트—큰 엔드투엔드 테스트를 포함해—는 통과했습니다. 하지만 전체 시스템을 개발 모드나 스테이징에서 실행하면, make를 포함한 실행 파이프라인만 실패했습니다. 자식 프로세스가 STDOUT으로 내보낸 데이터를 처리하려고 하면 파싱 실패 오류가 발생했습니다.
문제는 스테이징/개발 환경을 다른 make 레시피로 구동하고 있었다는 데 있었습니다. 이 레시피의 목적은 환경을 준비한 뒤 대화형 시스템 셸을 띄우는 것이었습니다. 이 기묘한 버그의 원인은 두 가지였습니다.
make가 모범 사례에 어긋나게도 진단 메시지를 STDOUT에 출력한다는 점.make가 내부에서 또 다른 make를 실행할 때, 최상위에서 호출될 때는 출력하지 않던 진단 메시지를 출력하기 시작한다는 점.
이 두 가지가 STDOUT 오염으로 이어졌고, 그 결과 하위 프로그램들이 출력을 파싱할 수 없게 되었습니다. 문제를 정확히 집어낸 뒤에는 수정이 간단했습니다.
λ git diff e019ee
diff --git a/bakery/src/singleplayer.rs b/bakery/src/singleplayer.rs
index f133d1d..653f395 100644
--- a/bakery/src/singleplayer.rs
+++ b/bakery/src/singleplayer.rs
@@ -169,6 +169,7 @@ pub fn simple_singleplayer(
// If everything is OK, run the checker
if let Ok(run_output) = &submission_output.run_output {
let mut checker_process = match Command::new("make")
+ .arg("--silent")
.arg("check")
.arg(format!("input={}", input.to_str().unwrap()))
.current_dir(checker) // Set the current directory to the checker directory
이 글에서 드리는 마지막 조언: 코드에서 make를 실행할 땐 항상 --silent를 붙이세요!
stdio를 깊이 있게 파헤친 이 여정이 즐거우셨길 바랍니다. 이제 속과 겉—장단점을 두루 아신 느낌이 들었으면 좋겠네요. 의도한 말장난입니다!
이 글을 위해 작성한 코드는 여기에서 확인할 수 있습니다: 링크.
echo "background_task(1)" | python3 early.py로 스크립트를 실행하면, 작업이 완료되지 않습니다.
❯ echo 'background_task(1)' | python3 early.py
Python 3 early exit demo. Available extra commands:
background_task(n) -> start a slow task that sleeps n seconds
results -> see which tasks have finished
Press Ctrl-D to exit the shell.
>>> Starting background task: sleeping 1s
>>>
now exiting InteractiveConsole...
Exited the shell. Let's see if tasks completed.
results = {}