IPC는 프로세스 간의 통신을 의미하며, 관련하여 OS에서 제공하는 여러가지 기술과 메커니즘이 존재한다.
왜 필요한가?
우선 프로세스는 자신만의 가상 메모리를 가지며 자신만의 작업을 수행한다. 프로세스별 자원을 분리시키는 것에는 시스템을 보호하기 위해서, 여러가지 작업을 병렬적으로 수행하기 위해서, 효율성을 위해서와 같이 다양한 이유가 존재한다.
하지만, 분명히 프로세스간에도 통신이 필요한 상황이 존재한다. 아주 단순하게 생각하면 모든 프로세스끼리 자원을 공유하게 하는 방법으로도 프로세스간 통신이 가능하겠지만, 상당히 비효율적이고 시스템 또한 전혀 보호되지 않아 많은 문제가 발생할 것이다.
그렇기 때문에, OS는 다양한 IPC 메커니즘을 제공하여 프로세스간 통신을 원할하게 한다. 어떤 방법이 있는지 하나씩 살펴보자.
Pipes
파이프는 한 프로세스가 다른 프로세스로 데이터를 보낼 수 있는 단방향 통신 채널로 시스템 콜의 일종이다.
다음은 fork() 와 pipe() 를 함께 사용하여 부모 프로세스에서 자식 프로세스로 메세지를 전달하는 예시이다.
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
int pipe_fd[2];
pid_t pid;
char message[] = "Hello, child process!";
char buffer[25];
if (pipe(pipe_fd) == -1) {
perror("pipe");
return 1;
}
pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid > 0) { // Parent process
close(pipe_fd[0]); // Close read end of the pipe
write(pipe_fd[1], message, strlen(message) + 1); // Write to the pipe
close(pipe_fd[1]); // Close write end of the pipe
} else { // Child process
close(pipe_fd[1]); // Close write end of the pipe
read(pipe_fd[0], buffer, sizeof(buffer)); // Read from the pipe
printf("Child received: %s\n", buffer);
close(pipe_fd[0]); // Close read end of the pipe
}
return 0;
}
셸에서도 이를 아주 간단하게 사용할 수 있다.
$ echo "Hello, world!" | cat
파이프는 unnamed pipe, named pipe 로 구분할 수 있는데, 위 코드와 커맨드의 예시는 unnamed pipe 를 사용하는 법을 보여준다.
unnamed pipe 는 주로 부모, 자식 프로세스 관계에서 사용되며, 별다른 식별자 없어도 file_descriptor 를 통해 연결될 수 있다. 그에 비해 서로 전혀 관련이 없는 프로세스 끼리의 파이프 연결에는 named pipe 가 활용되며 이 때는 파일의 이름을 식별자로 사용하여 파이프를 연결한다.
파이프는 기본적으로 FIFO 방식으로 동작한다.
Message queues
프로세스간 큐를 통해 메시지를 주고받을 수 있는 메커니즘으로 여러가지 시스템 콜을 활용한다.
메시지 큐에 구현에는 UNIX 방식과 POSIX 방식이 존재한다. 두 표준의 기본적인 메커니즘은 같지만 비교적 최근에 개발된 POSIX IPC 직관적으로 구성되어 있어 상대적으로 조금 더 사용하기 쉽다고 한다.
다음은 POSIX 방식으로 구현된 Message queues 를 사용하는 코드이다.
posix_sender.c
#include <fcntl.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
int main() {
mqd_t mq;
struct mq_attr attr;
char message[256];
attr.mq_flags = 0;
attr.mq_maxmsg = 10;
attr.mq_msgsize = 256;
attr.mq_curmsgs = 0;
mq = mq_open("/posix_msg_queue_example", O_CREAT | O_WRONLY, 0644, &attr);
if (mq == (mqd_t)-1) {
perror("mq_open");
exit(1);
}
printf("Enter a message: ");
fgets(message, sizeof(message), stdin);
if (mq_send(mq, message, strlen(message) + 1, 0) == -1) {
perror("mq_send");
exit(1);
}
printf("Message sent: %s", message);
mq_close(mq);
return 0;
}
posix_receiver.c:
#include <fcntl.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
int main() {
mqd_t mq;
struct mq_attr attr;
char message[256];
mq = mq_open("/posix_msg_queue_example", O_CREAT | O_RDONLY, 0644, NULL);
if (mq == (mqd_t)-1) {
perror("mq_open");
exit(1);
}
if (mq_receive(mq, message, sizeof(message), NULL) == -1) {
perror("mq_receive");
exit(1);
}
printf("Message received: %s", message);
mq_close(mq);
mq_unlink("/posix_msg_queue_example");
return 0;
}
terminal:
$ gcc posix_sender.c -o posix_sender -lrt
$ gcc posix_receiver.c -o posix_receiver -lrt
$ ./posix_receiver
$ ./posix_sender
Enter a message: Hello, POSIX message queue!
Message sent: Hello, POSIX message queue!
코드와 동작을 간단하게 파악해보자
sender 와 receiver 모두 mq_open() 시스템 콜을 통해 message queue 를 사용할 준비를 마친다. 여기서 "/posix_msg_queue_example" 은 일반적인 파일이 아닌, message queue 를 구분하기 위한 식별자라고 생각할 수 있다.
mq_open() 을 O_CREAT 플래그와 함께 호출하면, 해당 식별자를 가진 message queue 가 없다면 이를 생성하고, 있다면 이에 바로 접근할 수 있다.
이후 sender 에선 mq_send() 를 호출하여 메세지를 전달하고, receiver 에서는 메세지를 읽는다.
mq_open() 을 통해 생성되는 message queue 는 커널 메모리에 존재하며, 커널에 의하여 관리된다. 즉 위 메세지 송수신 동작을 송신자는 커널 메모리에 값을 쓰고, 수신자는 커널 메모리에서 이를 읽어오는 과정이라고 볼 수 있다.
Shared memory
공유 메모리는 여러 프로세스가 접근할 수 있는 메모리 영역을 의미한다. 데이터를 복사할 필요 없이 직접 메모리에 접근할 수 있기 때문에 매우 빠르게 동작한다. 다만 이의 경우 경쟁 조건을 피하고 데이터 일관성을 유지하기 위해 세마포어와 같은 적절한 동기화 메카니즘이 필요하다.
message queue 와 마찬가지로 여러 시스템 콜을 활용한다.
다음은 POSIX 방식으로 구현된 shared memory를 사용하는 코드이다.
posix_shm_writer.c:
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main() {
int shm_fd;
char *shared_memory;
const char *name = "/posix_shm_example";
shm_fd = shm_open(name, O_CREAT | O_RDWR, 0644);
if (shm_fd == -1) {
perror("shm_open");
exit(1);
}
ftruncate(shm_fd, 256);
shared_memory = (char *)mmap(0, 256, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
if (shared_memory == MAP_FAILED) {
perror("mmap");
exit(1);
}
printf("Enter a message: ");
fgets(shared_memory, 256, stdin);
printf("Message written to shared memory: %s", shared_memory);
munmap(shared_memory, 256);
close(shm_fd);
return 0;
}
posix_shm_reader.c:
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main() {
int shm_fd;
char *shared_memory;
const char *name = "/posix_shm_example";
shm_fd = shm_open(name, O_RDONLY, 0644);
if (shm_fd == -1) {
perror("shm_open");
exit(1);
}
shared_memory = (char *)mmap(0, 256, PROT_READ, MAP_SHARED, shm_fd, 0);
if (shared_memory == MAP_FAILED) {
perror("mmap");
exit(1);
}
printf("Message read from shared memory: %s", shared_memory);
munmap(shared_memory, 256);
close(shm_fd);
shm_unlink(name);
return 0;
}
terminal:
$ gcc posix_shm_writer.c -o posix_shm_writer
$ gcc posix_shm_reader.c -o posix_shm_reader
$ ./posix_shm_writer
Enter a message: Hello, POSIX shared memory!
Message written to shared memory: Hello, POSIX shared memory!
another terminal:
$ ./posix_shm_reader
Message read from shared memory: Hello, POSIX shared memory!
코드를 간단하게 파악해 보자. writer 와 reader 모두 shm_open()와 mmap() 시스템 콜을 활용하여 공유메모리에 생성 및 접근하며, 프로세스 자신의 메모리와 이를 mapping 시킨다.
공유 메모리는 OS에 의하여 디스크의 파일형태로 관리된다. 각 프로세스에선 mmap() 을 통해 해당 파일은 자신의 프로세스 메모리 공간의 매핑하는 방식으로 공유메모리에 직접 데이터를 읽고 쓸 수 있다. 즉, 공유 메모리 영역이 각 프로세스의 사용자 공간에 존재한다고 생각할 수 있으며, OS가 이를 공유 메모리 영역과 동기화 되도록 관리한다고 볼 수 있다.
Message queues 와의 차이점
message queue 와의 차이점은 message queue 는 커널 영역의 메모리에서 값을 복사해오는 방식이었다면, shared memory 는 해당 영역에 직접 접근하여 데이터를 읽어오는 방식이라는 것이다.
이에 따라 shared memory 의 동작이 message queue 에 비하여 더욱 빠르겠지만, 동기화 문제가 발생할 수 있어 조심해야 한다. semaphore, mutexes, condition variable 과 같은 동기화 기법들과 함께 사용해야할 것이다.
Memory mapped
위 shared memory의 예시 코드에서 mmap() 시스템 콜이 중요하게 사용되는 것을 확인하였다. 해당 시스템콜은 디스크의 파일을 프로세스의 주소 공간에 매핑하여 프로세스가 마치 메모리를 사용하는 것처럼 파일과 상호 작용할 수 있도록 해주는 기능을 제공한다. 이를 통해 효율적으로 파일 I/O 작업을 처리할 수 있다.
파일 I/O 를 위한 memory-maaped 의 구현은 다음과 같다.
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main() {
int fd;
char *mapped_memory;
const char *file_name = "example_file.txt";
const size_t file_size = 256;
// Create or open the file
fd = open(file_name, O_RDWR | O_CREAT, 0644);
if (fd == -1) {
perror("open");
exit(1);
}
// Set the file size
ftruncate(fd, file_size);
// Map the file into memory
mapped_memory = (char *)mmap(NULL, file_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (mapped_memory == MAP_FAILED) {
perror("mmap");
close(fd);
exit(1);
}
// Write data to the mapped memory
printf("Enter a message: ");
fgets(mapped_memory, file_size, stdin);
// Unmap the memory and close the file
munmap(mapped_memory, file_size);
close(fd);
return 0;
}
open() 시스템 콜을 통해 파일을 생성하고, mmap() 으로 이를 매핑하여 사용한다. 이 방식을 통해 파일 I/O 속도를 높일 수 있을 것이다.
memorry-mapped 파일 자체는 본질적으로 IPC의 한 유형은 아니지만(주로 파일 I/O 를 처리하기 때문에), 여러 프로세스가 동일한 파일을 각자의 주소 공간에 매핑할 때 공유 메모리를 구현하는 데 사용할 수 있다. 해당 경우 memorry-mapped 파일이 공유 메모리의 역할을 한다.
Semaphores
OS의 동기화 문제와 관련해서 많이 등장하는 개념이다. 이 또한 IPC 메커니즘의 일종이라고 볼 수 있으며, 여러가지 시스템 콜을 활용한다.
이는 여러 프로세스의 공유 리소스(공유 메모리)에 대한 액세스를 관리하는데 도움이 되는 동기화 pimitivies 로, 세마포어는 두개의 프로세스를 위한 binary (mutex) 방식으로 사용될 수도 있고, 여러개의 프로세스를 위한 counting semaphores 방식으로도 사용될 수 있다.
이는 공유 리소스를 사용할 수 있을 때 프로세스에게 signal 을 보내거나, 여러 프로세스간의 작업을 조정한다.
다음은 shared memory 와 함께 사용되는 semaphores 의 예시 코드이다.
#include <fcntl.h>
#include <semaphore.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define ITERATIONS 100000
int main() {
sem_t *semaphore;
int *counter;
pid_t pid;
// Initialize semaphore
semaphore = sem_open("/posix_semaphore_example", O_CREAT | O_RDWR, 0644, 1);
if (semaphore == SEM_FAILED) {
perror("sem_open");
exit(1);
}
// Allocate shared memory for counter
counter = (int *)mmap(0, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (counter == MAP_FAILED) {
perror("mmap");
exit(1);
}
*counter = 0;
pid = fork();
if (pid == -1) {
perror("fork");
exit(1);
}
if (pid == 0) {
// Child process: increment counter
for (int i = 0; i < ITERATIONS; i++) {
sem_wait(semaphore);
(*counter)++;
sem_post(semaphore);
}
} else {
// Parent process: decrement counter
for (int i = 0; i < ITERATIONS; i++) {
sem_wait(semaphore);
(*counter)--;
sem_post(semaphore);
}
wait(NULL); // Wait for child process to finish
printf("Counter value: %d\n", *counter);
// Clean up
sem_close(semaphore);
sem_unlink("/posix_semaphore_example");
munmap(counter, sizeof(int));
}
return 0;
}
terminal:
$ gcc semaphore_example.c -o semaphore_example -pthread
$ ./semaphore_example
Counter value: 0
위 코드에선 shared memory 를 통해 counter 값을 공유변수로 관리한다.
일반적인 상황에선 두가지 프로세스가 스케쥴링 되는 주기 및 횟수는 동일하지 않고, 스케쥴링 되더라도 명령어를 실행중에 다시 timer interrupt 로 인해 수행이 중단될 수 있기 때문에 최종 counter 값은 일정하지 않을 것이다.
이러한 경우에 semaphore 를 사용한다. sem_open 시스템 콜을 통해 세마포어를 생성하면, 이는 커널 메모리에서 존재하여 여러 프로세스나 스레드가 접근할 수 있도록 유지된다. sem_wait() 을 통해 각 프로세스는 자신이 해당 semaphores 와 관련된 공유 자원을 사용할 것을 요청하고, 만약 이를 사용하는 다른 프로세스가 없다면 이후 동작을 수행하며, 있다면 해당 프로세스는 CPU를 양도하고 sleep 상태로 전환된다.
다른 프로세스가 공유 자원과 관련된 작업을 완료하고, sem_post() 를 호출하면 이를 기다리고 있던 프로세스에게 관련 신호(SIGTERM 과 같은 signal 이 아닌, 운영 체제에서 제공하는 내부 신호 메커니즘이다)가 전달되어 sleep 되어 있던 프로세스가 깨어난다. 만약, 여러개의 프로세스가 이를 기다리고 있었다면, OS 스케쥴러에 따라 먼저 스케쥴링된 프로세스가 이를 취득하게 될 것이다.
Socket
네트워크를 통해 데이터를 전송하는데 사용되는 소켓 또한 IPC 의 일종이다. 일반적으로 네트워크상에서 먼 거리에 떨어져서 존재하는 end-to-end 간의 데이터 통신에 사용되며, 이 또한 다양한 시스템 콜을 통해 사용이 가능하다.
이와 관련한 구현과 설명은 쉽게 찾아볼 수 있어 생략하겠다.
소켓 또한 결국 프로세스간 통신을 위한 메커니즘으로, 같은 머신내의 프로세스 끼리도 충분히 동작한다. 그런데 같은 머신 내에서는 message queue만 사용해도 충분할 것 같은데 socket 을 사용할 일이 있는걸까? 이에 대한 답은 다음과 같다.
소켓은 다양한 운영 체제 및 플랫폼에서 광범위하게 지원되므로 이는 IPC를 위한 이식성 있는 선택이 될 수 있다. 또한, 개발중인 애플리케이션이 다른 컴퓨터에서 실행 중인 프로세스와 통신해야 하는 경우에 로컬 머신에서도 네트워크 통신을 대비하기 위해 소켓을 사용해야 할 것이다.
사실 간단하게 생각해봐도, 로컬 머신에서 내가 직접 서버 어플리케이션을 제작하고, 관련하여 요청을 보내며 테스트 할 때 소켓이 사용될 것이다.
Environment variables
주로 시스템 콜을 사용하는 IPC 기법들에 대하여 살펴보았는데, 시스템 콜을 사용하지 않는 방법도 다양하게 존재한다. 표준 라이브러리 dl 부모 프로세스에서 환경 변수를 설정하고, 자식 프로세스에서 이를 받아와 사용하는 것 또한 IPC의 한 예시라고 볼 수 있다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
setenv("MY_VAR", "Hello, World!", 1);
if (fork() == 0) {
char *value = getenv("MY_VAR");
printf("Child process reads MY_VAR: %s\n", value);
exit(0);
}
wait(NULL);
return 0;
}
setenv(), getenv() 는 시스템 콜이 아닌 표준 라이브러리 함수이다.
Signals
시그널은 OS에 의하여 프로세스에게 전달되는 메시지 혹은 이를 전달하는 메커니즘을 의미한다. 시그널 역시 IPC를 위해 사용될 수 있다.
다양한 종류의 시그널이 OS에 사전에 정의되어 있는데, 예를 들어 SIGTERM, SIGKILL 과 같이 프로세스를 종료하라는 명령어나, SIGSEGV 와 같이 프로세스 실행 중 허용되지 않은 메모리 영역에 접근을 시도했을 때 발생하는 시그널 등이 있다.
커널은 각 프로세스별로 시그널 테이블을 관리하며, 해당 테이블에는 각각 어떤 동작을 수행해야하는지 정의하는 핸들러를 등록할 수 있다. 만약 해당하는 핸들러가 없다면, 기본적으로 등록되어있는 동작이 수행된다. 이는 커널모드에서 유저모드로 전환될 때 해당 프로세스에 등록된 signal 을 확인하여 처리된다. (즉 인터럽트 만큼 즉각적으로 반응하진 않을 것이다) 만약 프로세스 수행중에 시그널이 등록되더라도, 이는 다음 스케쥴링 될 때 처리될 것이다.
시그널은 인터럽트 핸들러 수행 중 등록될 수도 있으며, 프로세스 내부에서도, 다른 프로세스에서도 등록이 될 수 있다. 다음은 IPC를 위한 시그널 활용의 예시이다.
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void signal_handler(int signum) {
printf("Received signal %d\n", signum);
exit(0);
}
int main() {
signal(SIGUSR1, signal_handler);
pid_t child_pid = fork();
if (child_pid == 0) {
pause(); // Wait for a signal
return 0;
}
sleep(1);
kill(child_pid, SIGUSR1);
wait(NULL);
return 0;
}
terminal:
Received signal 10
main() 함수에서 SIGUSR1(사용자 정의 시그널)를 핸들러와 함께 등록하고, 부모 프로세스에선 자식 프로세스가 시그널을 기다리는 pause() 를 호출할 수 있도록 1초를 sleep 한 위에 kill() 함수를 통해 시그널을 전달한다.
이 때 kill() 이라는 함수 이름 때문에 해당 동작이 헷갈렸었는데, 이는 그저 해당하는 pid에 signal 을 전달하는 함수이지 프로세스를 종료하는 동작과는 관련 없다(SIGKILL 시그널을 보내면 프로세스를 종료시키는 용도이겠지만)고 생각하면 된다. 이는 유닉스 시스템에서 유래되었으며, 처음에는 프로세스에 종료 신호를 보내는데 사용되었기에 함수 이름이 kill으로 정의되었으나, 시간이 지남에 따라 더 광범위한 신호를 수용하도록 발전되었다고 한다.
+ rpc 와 관련한 내용도 공부하여 추가하면 좋을 것 같다
참고 문헌
'운영체제' 카테고리의 다른 글
Segmentation & Paging (0) | 2023.06.15 |
---|---|
Synchronization (0) | 2023.05.29 |
Scheduling (1) | 2023.05.13 |
Virtual Memory (0) | 2023.05.03 |