수업/System Programming

[System Programming] Semaphore

hw-ani 2022. 12. 3. 18:30

이번에는 Inter Process Communication 수단? 이라기보단 IPC 보조 수단인 Semaphore에 대해 알아본다.

 

 

우선 앞서 배운 shared memory의 문제점을 짚어보고, Semaphore의 필요성을 알아보자.

사실 shared memory든 뭐든, 공유자원은 동시에 접근할 경우 문제가 생기기쉽다. 특히 특정 자원에 여러 processes가 동시에 write를 하는 경우 그렇다.(write하는게 한 놈 뿐이라면 아마 문제가 없지 않을까 싶음)

예를들어 특정 shm의 초기값이 0일때 해당 shm에 대해 동시에 두 프로세스에서 각각 100000번씩 +1을하는 loop를 돌리면 200000이 나와야하겠지만, 실제론 그렇지 않을 수 있다는 말이다.(실제로 test해보려면 loop문 안에서 살짝 딜레이 줘야함. 안그러면 하나 실행시키면 다른 놈 실행시킬 틈 없이 바로 끝나버림)

이렇게 공유자원에 접근해서 발생하는 문제를 보통 race condition이라고 한다.

 

이는 multi core라서 발생하는 문제인가 생각할 수도 있는데, CPU가 하나여도 이런 문제는 발생한다.

OS의 스케줄러가 두 프로세스를 번갈아 가며 실행하기 때문인데 "바꿔가며 실행해도 +1씩 하는건데 순서가 상관있나?" 하는 의문이 들 수 있다.

하지만 잘 생각해보면 'x=x+1;'같은 code가 실제 machine instruction으로 바뀌면 여러 줄이 된다. 즉 해당 연산이 atomic하지 않기때문에 문제가 발생한다. 여기서 atomic하다는 것은 말그대로 연산이 더 이상 쪼개지지 않는 경우를 말한다, 예를들어 machine instruction처럼.

machine instrcution도 HW까지 내려가서 더 쪼갠다면 atomic이 아니지 않나?
: 뭐 그렇게 내려가자면 진짜 원자가 나올때까지 한도 끝도 없겠다만,, 아마 ISA 기점으로 그 밑의 H/W들은 당연히 race condition이 없도록 설계했지않을까,, 그러니까 가장 밑단은 machine instructions이라고 봐도 되는거지.

 

특정 시나리오를 보면서 어떻게 문제가 될 수 있는지 알아보자.

P1과 P2에서 각각 0으로 초기화된 shm의 값에 대해 'x=x+1'을 수행한다고 해보자. 이 코드는 아마 load → add → store 의 machine instruction으로 바뀔 것이다.

만약 P1에서 load까지만 하고 특정 이유로 스케쥴러가 P1을 쫓아내고 P2를 CPU에 올려서 실행한다고 해보자.

나중에, 하던 작업을 다시 하기위해, process가 쫓겨날때는 CPU에서 실행중이던 registers 등 정보를 memory 어딘가에 저장해둔다.

그 다음에 P2가 실행되는데 얘는 막힘없이 쭉 실행됐다고 해보자. 공유메모리의 값을 100까지 증가시켜뒀다고 해보자.

그러고나서 이번엔 P2가 쫓겨나고 P1이 CPU에 올라가서 실행된다. P1의 정보들이 CPU로 올라오면 제일 처음 load할때 값인 0을 불러오게되고, 여기에 1을 'add' 한 다음 'store' 을 해서 P2에서 업데이트 시킨 값이 덮어씌워져버린다.

이렇게 실행중인 process를 교체하는 것을 Context Switching이라고 한다.

 

어떻게 해결?

C language의 모든 연산을 atomic하게 만들 순 없을 것이다.(그럼 machien instructions과 차이가 없음...)

간단하게 공유자원에 접근할 수 있는 process 수를 제한하면 된다.

전통적인 해결 방식은, 모든 process에서 공유하는 변수를 하나 만들어 이용하는 것이다. 예를들어 해당 변수가 1이면 공유 자원에 접근 가능한 상태로 보고, 0이라면 불가능한 상태로 본다. 특정 Process는 공유 자원에 접근할때 해당 값을 1 감소시키고, 나올때 1 증가시킨다. 그러면 한번에 한놈씩만 접근할 수 있게된다.

하지만 이 공유 변수를 아무거나 막 쓰게되면, 위에서 봤듯이 공유메모리 접근 오류가 발생할 수 있다. 그래서 보통 Semaphore나 Mutex Lock을 통해 공유 변수를 사용함으로써 문제를 해결한다.

 

프로세스나 쓰레드들의 수행 시점을 조절하여 서로 정보가 일치하도록 만드는 것동기화라고 한다.
동기화 도구에는 크게 SemaphoreMutex Lock이 있는데, 이 중 우린 Semaphore에 대해 알아본다.

 

 

 

정리하자면, 일반 code들은 atomic하지 않으므로 공유메모리에 process가 두개이상 접근하면 문제가 생길 수 있다. 이걸 해결하기 위해 특정 전역 변수를 이용해 통제할 수 있다. 하지만 그 변수가 저장된 곳 또한 공유 자원이기 때문에 문제가 될 수 있다. 그래서 이 공유변수만큼은 문제가 생기지 않게 atomic하게 접근하도록 Semaphore 개념을 사용한다.

(두번 돌아가는 느낌이 들 수도 있긴한데, 그렇다고 모~든 code를 atomic하게 만들 순 없다. atomic한 Semaphore 개념을 공통으로 만들어두고 다른 문제를 해결하는 것이다.)

 


우선 Semaphore의 기본 개념부터 알아보자.

Semaphore는 Edsger Wybe Dijkstra 가 고안한 두 개의 원자적 함수로 조작되는 정수 변수이다.

S가 그 변수이고, 해당 변수에는 P와 V라는 원자(atomic)적 함수(명령)으로만 접근할 수 있다. 

P는 critical section(임계구역)에 들어가기 전에 수행되고, V는 critical section(임계구역)에서 나올때 수행된다.

여기서 P와 V가 atomic하다고 했는데, 두 놈 자체가 atomic하다기보단 둘 안의 S를 변경하는 부분이 atomic하다는게 정확한 표현이다.

 

아래 두 코드는 P와 V의 동작을 이해하기위해 간단하게 구성한 코드이다. 실제론 다르게 구현될 수 있다. 특히 P 코드는 빈 loop를 수행하므로 비효율 적이다.

 P {
     while (S<=0)
     	;
     S--;
 }

 V {
     S++;
 }

 

 

두 함수 P와 V가 atomic하다고 했는데, 위에서도 말했지만 이는 두 연산이 쪼개지지않는 기본 연산이라 공유메모리 사용시 문제를 일으키지 않는다는 말과 같다.

공유 자원을 사용하는 code들을 모드 critical section으로 인식하고, 해당 code 이전에 P, 이후에 V를 배치시키면 된다.
그러면 여러 놈들이 P에 걸려있을때, 다른 곳에서 공유 자원 접근을 마친 후 V를 실행시키면 P에 걸려있는 놈들 중 한놈이 그 다음으로 공유 자원에 접근한다. 즉 한번에 한놈씩만 접근할 수 있다.

Q. P와 V는 어떻게 atomic이지? 결국 C로 구현된 함수가 아닌가?
A. semaphore 개념을 구현해둔 OS라면, 보통 특정 instructions을 활용해 해당 함수(시스템콜)를 지원할 것이다.
test&set 이라는 instruction을 활용하여 OS에서 system call로 아래에서 설명할 함수들을 지원해주기 때문에 우린 atomic 할 것이라고 믿고, 동기화할때 쓰면 된다.
P와 V에서 S를 변경하는 부분이 atomic 하도록 OS에서 보장해준다.

 

 


동기화 도구로 활용할 수 있는 이런 Semaphore 개념을 우리가 사용할 수 있도록 Linux/Unix OS에서 제공해준다.

UNIX/LINUX에선 모든 processes에서 접근할 수 있는 kernel variable(=semaphore)을 가진다. 공유 자원에 접근하는 여러 process는 이 커널 변수로 협업할 수 있다.(Condition object는 process 내에서 global해서 여러 thread에서 사용하고, Semaphore는 System 전체에서 global하므로 여러 process에서 사용한다.)

 

아래 헤더에 정의된 data type과 함수들을 알아보자.

#include <semaphore.h>

 

> sem_t

OS에서 지원하는 semaphore를 나타내는 data type이다.

 

 

> sem_t *sem_open(const char *name, int oflag, ...);

첫번째 인자로 받은 name을 가진 semaphore를 연다.(이 이름으로 세마포어를 특정함. shared memory의 key 같은 것) file open하는 것처럼 뒤에 flag를 줄 수 있다.

O_CREAT를 flag로 설정하면, 해당 이름을 가진 semaphore 없을때 만든다. O_EXCL은 이미 semaphore가 존재하면 에러로 판단한다. 그래서 "O_CRAET | O_EXCL" 이라면, 이는 무조건 해당 semaphore가 없으니 만들겠다는 말이다, 이미 있으면 에러이고.

세번째 인자로는 만드는 semaphore의 권한을 줄 수 있다. ex)0666

네번째 인자로는 만드는 semahore에 초기값을 설정할 수 있다. 만드는 경우에만 적용되기때문에 이미 존재하는 semaphore를 open하는 경우라면 그냥 불러오기 때문에 이 인자는 의미가 없다.

이때 초기화시키는 값이 사실상 동시에 실행될 수 있는 processes/threads의 개수인 셈이다.

지난 글에서 봤듯이 'ipcs' 명령어를 치면 OS에서 관리중인 Shared Memory, Message Queue, Semaphore의 리스트가 나온다.
하지만 거기에 있는 semaphore는 좀 옛날? 방식이라, 지금 이렇게 sem_open으로 만드는 POSIX semaphore와는 다르다.
그래서 지금 이렇게 만들어진 semaphore는 /dev/shm/ 에 "sem.name" 형태로 위치한다.

 

 

> int sem_wait(sem_t *sem);

semaphore 개념의 P 연산에 해당하는 함수이다.

S가 0 이하라면 기다리고, S가 1 이상이면 (혹은 기다리다가 1 이상이 되면) S를 1 감소시키고 다음 code를 계속 수행한다.

critical section 이전에 호출한다.

 

 

> int sem_post(sem_t *sem);

semaphore 개념의 V 연산에 해당하는 함수이다.

S를 1 증가시킨다. critical seciton 이후에 호출한다.

 

 

> int sem_getvalue(sem_t *restrict sem, int *restrict sval);

첫번째 인자로 준 semaphore의 값을 두번째 인자에 받아와 디버깅 할 수 있도록 해준다. 반환값은 성공/실패를 의미

 

 

critical section은 제일 초반에 말한 공유 자원을 접근 하거나, 다른 process와 동시에 실행되면 안되는 코드 영역이다. 공유 자원 처리가 기본 개념이지만 그 외 다양한 경우에서도 활용할 수 있다.
이런 공유 자원에 접근하는 등의 행위를 critical section으로 인지하고, 앞뒤에 sem_wait(), sem_post() 함수를 배치시킴으로써 다른 프로세스들과 동기화할 수 있다.


참고로 이렇게 semaphore를 활용한 경우 gcc 컴파일시 -lpthread option을 줘야한다.

 

 


Semaphores는 그냥 변수라서 우리가 활용하기 나름이다. 가장 기본적인건 S를 하나 만들고 값을 1을 준 다음 process가 공유 자원에 접근할때마다 P와 V를 호출하는 것이다. 여기선 S가 "공유자원에 접근 가능한 processes의 수"라는 의미이지만, 이건 우리가 활용하기 나름이다.

마찬가지로 critical section도 정하기 나름이다. 공유자원 제한할때 뿐만아니라 서버 동접자 수를 제한하거나 할때 활용할 수도 있고, V와 P 사이에 어떤 코드를 두냐에 따라 자유롭게 정할 수 있다. critical section이 전부 다를 수도 있고..

 

지난 글에서 shared memory를 이용해 server-clients 프로그램을 작성했었는데(작성하진않고 책보라고 하긴함 ㅋㅋ),

여기서 배운 semaphore 개념을 이용해 해당 shared memory에서 server의 write와 clients의 read가 동시에 일어나지 않도록 조절하는 코드도 책 p.507부터 있다.

(그런데 이 코드에서 교수님께서 말씀하신 그 오래된 버전의 semaphore를 쓰는듯. 코드 막 자세히 보진말고 다시 볼때 흐름만 보면될듯.)

(그리고 이 코드는 server가 하나이고 client가 여럿인 경우에 대한 것이다. server 여러개면 공유자원에서 충돌 할 수도 있음.)

 

여기서 semaphore를 두개 선언해서 하나(S1)는 number of writer를 표현하고, 하나(S2)는 number of readers를 표현하도록 한다.(위에서 말했듯 semaphore는 정수 변수일뿐이니 활용하기 나름)

그럼 server process는 S2가 0이 될때까지 기다렸다가 실행하도록 하면 되고, client processes는 S1이 0이 될때까지 기다렸다가 실행되도록 하면 된다.

server가 실행되면 S1을 1 증가시키고, client가 실행되면 S2를 1 증가시킨다.

 

semaphore가 1이 아니라 특정 양수가 됐을때 동작하게하려면 어떻게 할까? 예를들어 S가 2일때 누군가 start되게 하고싶다면, V에서 S를 무조건 2 감소시키도록 하면 된다. 그럼 S가 1일땐 아무 동작 안하다가 2가돼야 동작을 하게 될 것이다.

 

message queue, socket이나 pipe 같은건 이렇게 접근 제한을 걸지도 않고 비교적 간단하긴하지만, server-clients에서 clients 숫자가 많다면 감당하기 어렵다. 그럴땐 shared memory가 낫다.
(pipe/socket에도 lock을 걸 수는 있다고 함)

 

ㅡㅡㅡ

과제에서 Semaphore를 활용해 process1(P1)과 process2(P2)가 shm에 데이터를 읽고 쓰며 소통하도록 해봤다.

진행 순서는

P1write->P2read->P2write->P1read->반복...

이다.

아이디어만 다시 적어보자면, 일단 Semaphore 2개, S1과 S2를 사용한다. 하나만 사용해선 저 둘이 저렇게 딱딱 번갈아가며 실행시키기 어렵다.

 

server-clients처럼 writer/reader 역할이 프로세스 별로 정해져있는게 아니기 때문에 그냥 P1의 실행가능 여부는 S1로 정해버리고, P2의 실행가능 여부는 S2로 정해버린다.(semaphore는 활용하기 나름,, 이번엔 또 다른 의미로 활용한 것)

그래서 S1은 1, S0은 0으로 초기에 설정을 해두고, P1은 S1에 대해 P2는 S2에 대해 V를 걸어둔다.

그리고 P1의 write이 끝나면 S2를 1 증가시키고 S1은 그대로 둔다.

S2의 read->write을 마친 후 S1을 1 증가시키고 S2는 그대로 둔다.

이러면 원하는 순서대로 잘 실행이 된다. semaphore 하나만 이용하면 V 걸려있는 processes 중 누가 풀려나서 실행될지 명확하지 않으므로 제대로 실행되지 않는다.

위처럼 아예 정수 변수 의미만 생각해서 활용할 수도 있다. 너무 틀에 얽매이진 말자.

 

 

ㅡㅡㅡ

semaphore 개념상 함수들

init : 딱 한번 S를 원하는 값으로 초기화 시켜주는 함수

wait : P

signal(post) : V


ㅡㅡㅡ

여기선 설명이 안 돼있는데, sem_init()으로 semaphore를 초기화 할 수도 있다.

근데 뭐 여기선 지금 아예 다른 process간 synchronization 하려는거라 sem_open()으로 하는 것 같은데, 쓰레드간 동기화를 할거면 sem_t 타입 변수랑 sem_init()으로도 충분하다.

찾아보니 실제로 처음 세마포어를 세팅할 때 쓰레드간 동기화에선 sem_init()을, 프로세스간 동기화에선 sem_open()을 주로 사용한다고 한다.