프로세스끼리 작업을 하다 보면 서로 데이터를 주고받아야 할 일이 생긴다. 프로세스끼리 통신을 하는 경우 누가 먼저 작업할지, 작업이 언제 끝날지 등을 서로 알려주어야 하는데 이를 동기화
라고 한다.
프로세스가 다른 프로세스와 데이터를 주고 받는 프로세스간 통신(IPC)
에는 같은 컴퓨터 내에 있는 프로세스 뿐 아니라 다른 컴퓨터에 있는 프로세스와의 통신도 포함된다.
프로세스 내부 데이터 통신 : 하나의 프로세스 내에 여러 개의 스레드가 존재하는 경우 스레드는 전역 변수나 파일을 이용하여 데이터를 주고받는다.
프로세스 간 데이터 통신 : 같은 컴퓨터에 있는 여러 프로세스끼리 통신하는경우, 공용 파일 또는 운영체제가 제공하는 파이프를 사용하여 통신한다.
네트워크를 이용한 데이터 통신 : 여러 컴퓨터가 네트워크로 연결된 경우 소켓을 이용하여 데이터를 주고받는다. 다른 컴퓨터에 있는 함수를 호출하여 통신하는 원격 프로시저 호출(RPC)
도 여기에 해당한다.
같은 컴퓨터에 있는 프로세스끼리도 소켓 통신을 할 수 있는데, IP주소로
127.0.0.1
과 같은 루프백 주소를 사용하면 된다. 하지만 소켓을 사용하려면 많은 전처리가 필요하므로 소켓통신을 사용할 이유가 없다.
프로세스 간 통신은 동시에 실행되는 프로세스끼리 데이터를 주고받는 작업을 의미한다. 통신은 데이터가 전송되는 방향에 따라 전이중(duplex) , 반이중(half-duplex) , 단방향(simplex) 통신으로 나뉜다.
전이중 통신 : 데이터를 동시에 양쪽방향으로 전송할 수 있는 구조로, 프로세스 간 통신에서는 소켓 통신이 양방향 통신에 해당한다.
반이중 통신 : 데이터를 양쪽 방향으로 전송할 수 있지만 동시에 전송하지는 못하는 구조이다.
단방향 통신 : 한쪽 방향으로만 데이터를 전송할 수 있는 구조로, 전역 변수와 파이프가 단방향 통신에 해당한다.
(전역 변수가 1개이면 한쪽 방향으로만 통신이 가능하며, 양방향 통신을 구현하려면 전역 변수 2개가 필요하다.)
전역 변수를 사용하는 통신 방식의 가장 큰 문제는 데이터를 보냈는지 상대편에서 알 수 없다는 것이다. 상태 변화를 살펴보기 위해 반복문을 무한정 기다리는 것을 바쁜 대기(busy waiting)
이라고 한다. 바쁜 대기는 자원이 낭비되므로 좋지 않은 프로그램의 전형적인 예이다.
바쁜 대기 문제를 해결하기 위해서는 데이터가 도착했음을 알려주는 동기화
를 사용한다. 프로세스간 통신은 동기화 기능이 있느냐 없느냐에 따라 동기화 통신(blocking communication)
과 비동기화 통신(non-blocking communication)
으로 구분된다.
전역 변수와 파일을 이용한 통신은 비동기화 통신의 대표적인 예이다. 비동기화 통신은 통신 오버헤드는 적지만 바쁜 대기처럼 사용자가 직접 처리해야 하는 작업이 많다
분류 방식 | 종류 | 예 |
---|---|---|
통신 방향에 따른 분류 | 전이중 통신 | 일반적 통신, 소켓 |
반이중 통신 | 무전기 | |
단방향 통신 | 전역 변수, 파일 , 파이프 | |
통신 구현 방식에 따른 분류 | 동기화 통신 | 파이프, 소켓 |
비동기화 통신 | 전역 변수, 파일 |
프로세스 간 통신은 데이터를 주거나 받는 동작으로 이루어지며 이는 쓰기 연산과 읽기 연산으로 간소화할 수 있다.
예를 들어 전역변수 GV를 이용하여 send는 쓰기 연산으로, receive는 읽기 연산으로 변경한 것으로 연산만 바뀌었을 뿐 의미는 같다.
send -> write(GV,message)
receive -> read(GV, message)
프로세스 간 통신에서 가장 중요한 것은 프로세스의 동기화이다. 전역 변수, 파일, 파이프, 소켓을 이용한 통신에서 프로세스 동기화가 어떻게 이루어지는지 알아보자.
전역 변수를 이용한 통신은 공동으로 관리하는 메모리를 사용하여 데이터를 주고받는 것이다. 데이터를 보내는 쪽에서는 전역 변수나 파일에 값을 쓰고, 데이터를 받는 쪽에서는 전역 변수의 값을 읽는다. 전역 변수를 이용한 통신은 주로 직접적인 관련이 있는 프로세스간에 사용한다. 예를 들면 부모 프로세스가 전역 변수를 선언한 후 자식 프로세스를 생성하면 부모-자식 프로세스간 통신이 가능하다.
<전역 변수를 이용한 통신>
int R;
int L;
int main(){
int pid;
pid = fork();
...
}
위 그림은 부모-자식 관계의 두 프로세스가 양방향으로 통신하는 모습을 나타낸 것이다. 양방향으로 통신하기 위해 전역 변수 R과 L을 정의한 후 fork()를 사용하여 부모 프로세스 P와 자식 프로세스 C를 만들었다. 두 프로세스는 전역 변수에 읽기 또는 쓰기 연산을 한다.
하지만 이와 같은 통신 방식에는 동기화 문제가 있다. 프로세스 P가 전역 변수 R에 쓰기 연산을 한 후 프로세스 C가 그 데이터를 읽으려 할 때, 프로세스 C는 전역 변수 R을 주기적으로 확인해야 한다.
프로세스가 입출력 관리 프로세스에 쓰기를 요구하면 데이터가 저장되고, 읽기를 요구하면 입출력 관리 프로세스로부터 데이터를 가져온다. 쓰기 연산은 하드디스크에 데이터를 전송하는 명령, 읽기 연산은 하드 디스크로 데이터를 가져오는 명령이라고 할 수 있으므로 파일 입출력도 통신이다.
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
inf fd;
char buf[5];
fd = open("com.txt" , 0_RDWR); // 읽기 쓰기 모드로 파일 디스크립터 생성
write(fd , "Test" , 5); // 하드디스크에 쓰기 (Test+null)
read(fd , buf , 5); // 하드디스크에서 읽기
close(fd);
exit(0);
}
파일을 이용한 통신은 부모-자식 프로세스 간 통신에 많이 사용되며 운영체제가 프로세스 동기화를 제공하지 않는다. 그래서 프로세스가 알아서 동기화를 해야 하는데 보통 부모 프로세스가 wait() 함수를 이용하여 자식 프로세스의 작업이 끝날 때까지 기다렸다가 작업을 시작한다.
프로세스 동기화 문제를 해결하는 방법으로 파이프가 있다. 파이프는 운영체제가 제공하는 동기화 통신 방식으로, 파일 입출력 과 같이 파일 디스크립터를 얻고 작업을 한 후 마무리한다. 파이프도 마찬가지로 양방향 통신을 하기 위해서 파이프 2개가 필요하다.
파이프는 이름 없는 파이프
와 이름 있는 파이프
로 나뉜다.
이름 없는 파이프 : 일반적으로 파이프라고 하면 가리키는 파이프. 부모-자식 등 서로 관련 있는 프로세스 간 통신에 사용된다.
이름 있는 파이프 : FIFO라 불리는 특수 파일을 이용하며 서로 관련 없는 프로세스 간 통신에 사용됨
여러 컴퓨터에 있는 프로세스 끼리도 통신을 네트워킹
이라고 한다. 네트워킹 상황에서의 통신은 원격 프로시저 호출이나 소켓을 이용한다. 프로시저 호출이 한 컴퓨터에 있는 함수를 호출하는 것이라면, 원격 프로시저 호출은 다른 컴퓨터에 있는 함수를 호출하는 것이다.
일반적으로 원격 프로시저 호출은 소켓을 이용하여 구현한다. 다른 컴퓨터에 있는 프로세스와 통신을 하기 위해서는 먼저 그 컴퓨터의 위치를 파악하고, 시스템 내 여러 프로세스 중 어떤 프로세스와 통신을 할지도 결정해야 한다. 이때 통신하고자 하는 프로세스는 소켓에 쓰기 연산을 하면 데이터가 전송되고, 읽기 연산을 하면 데이터를 받게 된다.
소켓은 프로세스 동기화를 지원하므로 데이터를 받는 쪽의 프로세스가 바쁜 대기를 하지 않아도 된다.
공유 자원
은 여러 프로세스가 공동으로 이용하는 변수, 메모리, 파일 등을 말한다. 공유 자원은 공동으로 이용되기 때문에 누가 언제 데이터를 읽거나 쓰느냐에 따라 결과가 달라진다.
위 그림을 보면 전역 변수 예금에 대해 두 프로세스가 입금을 한다. 두 프로세스 모두 예금 10만원을 동시에 읽은 후 다른 프로세스의 작업을 무시하고 덮어쓰는 바람에 총액이 맞지 않는 문제가 발생하였다.
이처럼 2개 이상의 프로세스가 공유 자원을 병행적으로 읽거나 쓰는 상황을 경쟁 조건(Race Condition)
이 발생했다고 한다. 경쟁 조건이 발생하면 위의 그림처럼 공유 자원 접근 순서에 따라 실행 결과가 달라질 수 있다.
공유 자원 접근 순서에 따라 실행 결과가 달라지는 프로그램의 영역을 임계구역이라고 한다. 예를 들어 위의 그림처럼 예금을 믹서처럼 임계구역으로 지정하면 문제가 발생하지 않는다. 어떤 프로세스가 임계구역에 들어가면 다른 프로세스는 임계구역 밖에서 기다려야 하며 임계구역의 프로세스가 나와야 들어갈 수 있다.
생산자-소비자 문제에서는 생산자 프로세와 소비자 프로세스가 서로 독립적으로 작업을 한다. 생산자는 물건을 생산해서 버퍼에 넣고 소비자는 계속 버퍼에서 물건을 가져온다. 또한 버퍼가 비었는지 가득 찼는지 확인하기 위해 sum이라는 전역 변수를 사용하는데 sum에는 현재 버퍼에 있는 상품의 총 수가 결정된다.
위 그림은 생산자-소비자 문제의 코드와 작동 상태를 나타낸 것이다. 생산자는 수를 증가시켜가며 물건을 채우고 소비자는 생산자를 쫓아가며 물건을 소비한다. 현재 버퍼는 1에서 3까지 차 있고 SUM 값은 3이다.
이 때 생산자 코드와 소비자 코드가 다음과 같이 동시에 실행되면 문제가 발생한다. 생산자와 소비자가 전역 변수 sum에 접근하는 타이밍을 서로 맞추기 않았기 떄문이다.
sum = sum +1;
과 소비자의 sum = sum-1;
이 거의 동시에 실행되면 문제가 발생한다. 생산자와 소비자가 독립적이기 때문에 상대방이 sum을 바꾸려는 것을 모른 채 현재 상태인 sum=3을 읽어서 작업한다.한 프로세스가 임계구역에 들어가면 다른 프로세스는 임계구역에 들어갈 수 없다. 이것이 지켜지지 않으면 임계구역을 설정한 의미가 없다.
어떤 프로세스도 무한 대기하지 않아야 한다. 즉 특정 프로세스가 임계구역에 진입하지 못하면 안 된다.
한 프로세스가 다른 프로세스의 진행을 방해해서는 안 된다.
임계구역 문제를 해결하는 단순한 방법은 잠금
을 이용하는 것이다. 임계구역 문제를 해결하기 위한 세 가지 조건 상호 배제
, 한정 대기
, 진행의 융통성
을 모두 만족하는 잠금, 잠금 해제 , 동기화 구현 방법을 알아보자
소프트웨어적으로 임계구역을 해결하는 방법을 살펴보자.
#include <stdio.h>
typedef enum {false , true} boolean;
extern boolean lock = false;
extern int balance;
main() {
while(lock==true);
lock = true;
balance = balance + 10; // 임계구역
lock = false;
}
C 언어에는 boolean 변수가 없어서 새로 정의하였다. 또한 잠금으로 사용할 전역 변수 lock과 임계구역에 사용할 예금 변수 balance도 정의했다.
프로세스 P1과 P2는 코드가 같으며 두 프로세스가 공유하는 변수인 lock의 초깃값은 false이다. 여기서 false는 잠금이 해제되었다는 의미이다.
프로세스 P1과 P2는 임계구역에 진입하기 전에 코드를 통해 임계구역에 잠금이 걸려 있는지 확인한다 (lock == true). 만약 잠겨있다면 무한 루프를 돌면서 잠금이 해제될때까지 기다린다. 임계구역에 진입할 경우 잠금을 걸고(lock = true) 작업을 하며, 작업을 마치면 다른 프로세스가 사용할 수 있도록 잠금을 해제한다.(lock = false)
하지만 이러한 코드도 가끔은 제대로 작동하지 않는다.
위 그림에서는 임계구역에 진입한 프로세스가 없다. 이런 상황에서 아래 순서대로 실행된다고 가정해보자.
또 다른 문제점은 잠금이 풀리기를 기다리려면 바쁜 대기를 한다는 것이다. 작업을 할 필요가 없음에도 무한 루프를 돌면서 자원을 낭비한다.
상호 배제를 보장하지 못하는 위의 그림을 보완한 모습이다. 전역 변수로 lock1과 lock2 를 사용하고 초깃값은 둘 다 false이다.
프로세스 P1은 임계구역에 진입하기 전에 먼저 잠금을 설정하고 프로세스 P2가 잠금을 설정했는지 확인한다. 만약 잠금을 설정하지 않았다면 임계구역에 진입하여 작업을 마친 후 잠금을 해제한다.
하지만 이러한 방식에서는 두 프로세스가 모두 임계구역에 진입하지 못하는 무한 대기 상태가 일어날 수 있다.
P1과 P2 둘 다 무한 루프에 빠져서 임계구역에 진입하지 못한다. 이는 한정 대기 조건을 보장하지 못하는 상황으로 교착 상태
라고 한다. 교착 상태란 프로세스가 살아 있으나 작업이 진행되지 못하는 상태를 말한다.
또한 프로세스가 3개 4개로 늘어나면 lock3 , lock4를 만드는 등 프로세스 갯수에 따라 검사해야하는 lock 변수도 많아져 비효율적이다.
아래 그림은 임계구역 문제를 해결하기 위한 세 번째 코드이다. lock 값이 1이면 프로세스 P1이 임계구역을 사용한다는 뜻이고, lock 값이 2이면 프로세스 P2가 임계구역을 사용한다는 뜻이다.
공유 변수 lock의 값을 통해 다른 프로세스가 임계구역에 있는지 확인하고, 없으면 진입한다. 위 그림은 잠금을 확인하는 문장이 하나이므로 상호 배제와 한정 대기를 보장한다. 그러나 서로 번갈아서 실행된다는 문제가 있다. 한 프로세스가 두 번 연달아 임계구역에 진입하고 싶어도 그럴 수 없다.
이렇게 프로세스의 진행이 다른 프로세스로 인해 방해받는 현상을 경직된 동기화라고 한다. 즉 위의 그림은 진행의 융통성 조건을 보장하지 못한다.
임계구역 문제는 하드웨어적인 방법으로도 해결할 수 있다. 아래 그림에서 코드는 잠금이 걸렸는지 검사하는 while문과 검사한 후 잠금 설정을 하는 lock=true 문이 분리되어 중간에 타임아웃이 걸리면 문제가 발생한다. 이 경우 하드웨어적으로 두 명령어를 동시에 실행하면 임계구역 문제를 쉽게 해결할 수 있다.
임계구역을 하드웨어적으로 해결하는 방법은 편리하지만 바쁜 대기를 사용하여 검사하기 때문에 자원 낭비가 있다.
변수 turn은 두 프로세스가 동시에 lock을 설정하여 임계구역에 못 들어가는 상황에 대비하기 위한 장치이다. 두 프로세스가 모두 lock을 설정했더라도 turn을 사용하여 다른 프로세스에게 양보하도록 한다.
예를 들어, P1에서 작업이 필요하다고 판단하여 lock1 = true 문을 실행하였다. 본래였으면 직후에 타임아웃이 되어 P2에서도 lock을 걸고 결과적으로 두 프로세스 모두 임계구역에 들어가지 못하는 상황이 발생하였다. 피터슨 알고리즘은 turn이라는 변수를 사용하여 추가적으로 다른 작업이 필요한 프로세스가 있는지 탐색한다.
피터슨 알고리즘은 임계구역 해결의 세 가지 조건을 모두 만족하지만 2개의 프로세스만 사용 가능하다는 문제점이 있다. 여러 프로세스가 하나의 임계구역을 사용하려면 공유 변수를 추가해야 한다.
데커 알고리즘이 어떻게 작동하는지 프로세스 P1의 입장에서 살펴보자.
세마포어
란 임계구역에 진입하기 전에 스위치를 사용 중으로 놓고 임계구역으로 들어간다. 이후에 도착하는 프로세스는 앞의 프로세스가 작업을 마칠 때까지 기다린다. 프로세스가 작업을 마치면 세마포어는 다음 프로세스에 임계구역을 사용하라는 동기화 신호를 보낸다. 세마포어는 다른 알고리즘과 달리 임계구역이 잠겼는지 직접 점검하거나, 바쁜 대기를 하거나, 다른 프로세스에 동기화 메시지를 보낼 필요가 없다.
Semaphore(n);
P();
/* 임계구역 */
V();
위의 코드는 세마포어를 구현한 간단한 코드이다.
세마포어에서 잠금이 해제되기를 기다리는 프로세스는 세마포어 큐에 저장되어 있다가 wake_up 신호를 받으면 큐에서 나와 임계구역에 진입한다. 따라서 바쁜 대기를 하는 프로세스가 없다. 그러나 세마포어의 P()나 V()내부 코드가 실행되는 도중에 다른 코드가 실행되면 상호 배제와 한정 대기 조건을 보장하지 못한다. 그러므로 P()와 V()의 내부 코드는 검사와 지정을 사용하여 분리 실행되지 않고 완전히 실행되게 해야한다.
위에서 봤던 그림을 세마포어로 해결해보자.
세마포어는 공유 자원이 여러 개일 때도 사용할 수 있다.
세마포어의 가장 큰 문제는 사용자의 잘못된 사용으로 인해 임계구역이 보호받지 못한다는 것.
공유 자원을 사용할 때 모든 프로세스가 세마포어 알고리즘을 따른다면 굳이 P()와 V()를 사용할 필요 없이 자동으로 처리하면 된다. 이를 실제로 구현한 것이 모니터이다. 모니터는 공유 자원을 내부적으로 숨기고 공유 자원에 접근하기 위한 인터페이스만 제공함으로써 자원을 보호하고 프로세스간 동기화를 시킨다.
안녕하세요 개발 공부를 하고 있는 학생입니다. 제가 공부한 내용을 블로그에 올리는 중인데 혹시 이 글에 있는 사진을 제 블로그에 올려도 될까요?