함수 A가 B를 호출할 때, 함수 B를 호출함과 동시에 OS가 함수 A가 실행중인 스레드나 프로세스를 일시 중지 시킨다 → 블로킹
그렇지 않으면 → 논-블로킹
블로킹 호출 핵심은 스레드/프로세스가 일시 중지되는 것
모든 함수 호출이 호출자의 스레드를 일시 중지 시키는 것은 아님
int sum(int a, int b)
{
return a + b;
}
void func()
{
int r = sum(1, 1);
}
위 코드는 운영체제가 func 함수 호출 시 해당 스레드를 중지시키지 않음. 핵심은 입출력
블로킹의 핵심 문제: 입출력
입출력 요청 완료시간은 CPU의 클럭 주파수보다 훨씬 느림
입출력 과정이 실행되는 동안 CPU 제어권을 다른 스레드로 넘겨 다른 작업을 할 수 있도록 해야 함
이후 입출력 작업이 완료되면 다시 CPU제어권을 우리 쓰레드/프로세스로 받아와 다음 작업을 실행할 수 있도록 함
CPU 제어권을 상실했다가 되찾는 시간 동안 블로킹되어 일시 중지됨
스레드 A가 입출력 작업을 실행하여 블로킹되면 CPU는 스레드 B에 할당됨
스레드 B가 실행되는 동안 OS는 입출력 작업이 완료된 것을 확인하면 다시 스레드 A에 CPU 할당
논블로킹과 비동기 입출력
네트워크 데이터 수신을 예로 설명. 데이터를 수신하는 함수인 recv가 논블로킹이면 이 함수를 호출할 때 OS는 스레드를 일시 중지시키는 대신 recv 함수를 즉시 반환
호출 스레드는 자신의 작업을 계속 진행하고 데이터 수신 작업은 커널이 처리
데이터를 언제 수신했는지 알 수 있는 방법은?
논블로킹 방식의 recv 함수 외에 결과를 확인하는 함수를 함께 제공하고, 해당 함수를 호출하여 수신된 데이터가 있는지 확인
데이터가 수신되면 스레드에 메시지나 신호등을 전송하는 알림 작동 방식
recv 함수를 호출 할 때 데이터 수신 처리를 담당하는 함수를 콜백 함수에 담아 매개변수로 전달할 수 있음(recv 함수가 콜백 함수를 지원해야 함)
논블로킹 호출이며, 이런 유형의 입출력 작업을 비동기 입출력 이라고도 함
피자 주문에 비유하기
피자를 직접 주문하러 피자가게에 가서 주문하고 기다려 받는것: 블로킹
피자 전화로 주문 후 받는 것: 논 블로킹
피자 완성됬는지 계속 체크하면? 동기
일반적으로는 비동기
동기와 블로킹
블로킹 호출은 확실한 동기 호출
동기호출은? 블로킹 호출이 아닐 수 있음
sum 함수에 대한 호출은 동기이지만 funcA가 sum 함수 호출을 했다고 해서 블로킹되거나 하지 않음
int sum(int a, int b)
{
return a + b;
}
void func()
{
int r = sum(1, 1);
}
비동기와 논블로킹
네트워크 데이터 수신을 예로 들어 설명
데이터를 수신하는 recv 함수를 논블로킹 호출로 설정하기 위해 flag 값 추가(NON_BLOCKING_FLAG)
void handler(void *buf)
{
//수신된 네트워크 데이터를 처리
}
while(true)
{
fd = accept();
recv(fd, buf, NON_BLOCKING_FLAG, handler); // 호출 후 바로 반환, 논블로킹
}
recv 함수는 논블로킹 호출 → 네트워크 데이터를 처리해주는 handler 함수를 콜백 함수로 전달, 즉 비동기이자 논 블로킹
하지만 데이터 도착을 감지하는 전용 함수인 check 함수를 제공해서 아래와 같이 구성한다면,
void handler(void *buf)
{
//수신된 네트워크 데이터를 처리
}
while(true)
{
fd = accept();
recv(fd, buf, NON_BLOCKING_FLAG, handler); // 호출 후 바로 반환, 논블로킹
while (!check(fd))
{
// 순환 감지
;
}
handler(buf);
}
while 문에서 끊임없이 데이터를 도착하기 전까지 체크하기 때문에 그 전에 handler 함수를 사용할 수 없음 ⇒ recv 함수는 논블로킹이지만 전체적인 관점에서 이 코드는 동기
상사가 프로그래머에게 일을 시킬 때, 옆에서 계속 기다리고 있으면 동기, 일을 시키고 다른 일을 하면 비동기라고 예를 들어 설명
또 다른 예로 전화통화와 이메일을 비교해보면,
전화통화 : 상대방이 말할 때 들으며 기다려야 함(동기)
이메일: 작성하는 동안 다른 사람들은 다른 일 처리 가능(비동기)
동기 호출
일반적인 함수 호출
funcA()
{
// funcB 함수가 완료될 때까지 기다림
funcB();
// funcB 함수는 프로세스를 반환하고 계속 진행
}
funcA와 funcB 함수가 동일한 스레드에서 실행됨
입출력 작업의 경우,
...
read(file, buf); // 여기에서 실행이 중지됨
...
// 파일 읽기가 완료될 때가지 기다렸다가 계속 실행함
최하단 계층은 실제로 system call로 운영체제에 요청을 보냄
파일 읽기 작업을 위해 read 호출 스레드를 일시 중지하고, 커널이 디스크 내용을 읽어 오면 일시 중지 되었던 스레드가 다시 깨어남 (Blocking input/output 이라고 함)
Blocking input/output
비동기 호출
디스크의 파일 읽고 쓰기, 네트워크 IO, 데이터 베이스 작업처럼 시간이 많이 걸리는 입출력 작업을 백그라운드 형태로 실행
read(file, buf); // 여기에서 실행이 중지되지 않고 즉시 반환
// 이후 내용의 실행을 블로킹하지 않고 바로
read 함수가 비동기 호출되면 파일 읽기 작업이 완료되지 않은 상태에서도 read 함수는 즉시 반환될 수 있음
호출자가 블로킹 되지 않고 read 함수가 즉시 반환되기 때문에 다음 작업을 실행할 수 있음
그럼 비동기 호출에서 파일 읽기 작업이 언제 완료되었는지 어떻게 알 수 있을까? 이 경우, 처리에 대한 2가지 상황이 있을 수 있음
호출자가 실행 결과를 전혀 신경쓰지 않을 때
호출자가 실행 결과를 반드시 알아야 할 때
1. 호출자가 실행 결과를 전혀 신경쓰지 않을 때
void handler(void* buf)
{
... // 파일 내용 처리 중
}
read(buf, handler)
"계속해서 파일을 읽고, 작업이 완료되면 전달된 함수(handler)를 이용해 파일을 처리해주세요." ⇒ 파일 내용은 호출자 스레드가 아닌 콜백 함수가 실행되는 다른 스레드(호출되는 스레드) 또는 프로세스 등에서 처리
2. 호출자가 실행 결과를 반드시 알아야 할 때
notification 작동 방식을 사용하는 것
작업 실행이 완료되면 호출자에게 작업 완료를 알리는 신호나 메시지를 보내는 것
결과 처리는 이전과 마찬가지로 호출 스레드에서 함
웹 서버에서 동기와 비동기 작업
아래의 작업을 한다고 가정
A, B, C 세 단계를 거친 후 데이터 베이스를 요청
데이터 베이스 요청 처리가 완료 되면 D, E, F 세 단계를 거침
A, B, C, D, E, F 단계에는 입출력 작업이 포함되어 있지 않음
// 사용자 요청을 하는 단계
A;
B;
C;
데이터 베이스 요청;
D;
E;
F;
먼저 가장 일반적인 동기 처리 방식
// 메인 스레드
main_thread()
{
while(1)
{
요청 수신;
A;
B;
C;
데이터베이스 요청을 전송하고 결과가 반환될 때까지 대기;
D;
E;
F;
결과 반환;
}
}
// 데이터베이스 스레드
database_thread()
{
while(1)
{
요청 수신;
데이터베이스 처리;
결과 반환;
}
}
데이터 베이스 요청 후 주 스레드가 블로킹 되어 일시 중지됨
데이터 베이스 처리가 완료된 시점에서 D, E, F가 계속 실행됨
주 스레드에서 빈공간은 유휴 시간(idle time)으로 기다리는 과정
유휴 시간을 줄이기 위해서 비동기 작업을 활용할 수 있다
1. 주 스레드가 데이터 베이스 처리 결과를 전혀 신경 쓰지 않을 때
주 스레드는 데이터 베이스 처리 완료 여부 상관하지 않음
데이터베이스 스레드가 D, E, F 세 단계를 자체적으로 직접 처리
데이터 베이스 처리 후 DB 스레드가 D, E, F 세 단계를 알 수 있는 방법은?
콜백 함수
void handle_DEF_after_DB_query()
{
D;
E;
F;
}
주 쓰레드가 데이터베이스 처리 요청을 보낼 때 위 함수를 매개변수로 전달
DB_query(request, handle_DEF_after_DB_query);
데이터 베이스 쓰레드는 데이터 베이스 요청을 처리한 후 handle_DEF_after_DB_query 함수 호출하기만 하면 됨
이 함수를 데이터 베이스 쓰레드에 정의하고 직접 호출하는 대신 콜백 함수를 통해 전달받아 실행하는 이유은?
⇒ 소프트웨어 조직 구조 관점에서 볼 때 데이터베이스 쓰레드에서 해야 할 작업이 아니기 때문
본 글은 책 "혼자 공부하는 컴퓨터 구조+운영체제" 의 Chapter 12. 프로세스 동기화 부분을 읽고 정리한 내용입니다.
동기화의 의미
동시다발적으로 실행되는 많은 프로세스는 서로 데이터를 주고 받으며 협력하며 실행될 수 있고 이 때 데이터의 일관성을 유지해야 함
프로세스 동기화란 프로세스 사이의 수행 시기를 맞추는 것을 의미하고 아래 2가지를 위헌 동기화가 있음
실행 순서 제어: 프로세스를 올바른 순서대로 실행하기
상호 배제: 동시에 접근해서는 안되는 자원에 하나의 프로세스만 접근하게 하기
실행 순서 제어를 위한 동기화
book.txt 파일에 2가지 프로세스가 동시에 실행중이라고 가정해보자
Writer process
Reader process
둘은 무작정 아무 순서대로 실행되어서는 안되고 Writer 실행이 끝난 후 Reader가 실행되어야 함
상호 배제를 위한 동기화
공유가 불가능한 자원의 동시 사용을 피하기 위해 사용하는 알고리즘
예시 (Bank account problem)
계좌에 10만원이 저축되어 있다고 가정
프로세스 A는 2만원 입금
프로세스 B는 5만원 입금
프로세스 A 실행되는 과정
계좌의 잔액 읽음
읽어들인 잔액에 2만원 더함
더한 값 저장
프로세스 B 실행되는 과정
계좌의 잔액 읽음
읽어들인 잔액에 5만원 더함
더한 값 저장
이때 두 프로세스가 동시에 실행되었다고 가정했을 때 동기화가 제대로 이루어지지 않은 경우 아래와 같이 전혀 엉뚱한 결과가 나올 수 있음
프로세스 A가 끝나지 않은 상황에서 프로세스 B가 시작됨
정상적으로 작동하길 기대되는 예시
한 프로세스가 진행중이면 다른 프로세스는 기다려야 제대로된 결과를 얻을 수 있는 예
생산자와 소비자 문제
생산자와 소비자는 ‘총합’이라는 데이터를 공유
생산자는 총합에 1을 더하고, 소비자는 총합에 1을 뺌
생산자와 소비자를 동시에 실행했을 때 예상되는건 총합의 값은 초기 상태를 유지하는 것
하지만 직접 코드를 돌려보면 예상치 못한 결과 발생
#include <iostream>
#include <queue>
#include <thread>
void produce();
void consume();
int sum = 0;
int main() {
std::cout << "초기 합계: " << sum << std::endl;
std::thread producer(produce);
std::thread consumer(consume);
producer.join();
consumer.join();
std::cout << "producer, consumer 스레드 실행 이후 합계: " << sum << std::endl;
return 0;
}
void produce() {
for(int i = 0; i < 100000; i++) {
sum++;
}
}
void consume() {
for(int i = 0; i < 100000; i++) {
sum--;
}
}
초기 합계 : 10 producer, consumer 스레드 실행 이후 합계: -13750
이는 생산자 프로세스와 소비자 프로세스가 제대로 동기화되지 않았기 때문
‘총합’ 데이터를 동시에 사용하는데 소비자가 생산자의 작업이 끝나기 전에 총합을 수정했고, 생산자가 소비자의 작업이 끝나기 전에 총합을 수정했기 때문
공유 자원과 임계 구역
shared resource: 여러 프로세스 혹은 쓰레드가 공유하는 자원
전역 변수, 파일, 입출력 장치, 보조기억장치
임계구역: 동시에 실행하면 문제가 발생하는 자원에 접근하는 코드 영역
두 개 이상의 프로세스가 임계 구역에 진입하고자 하면 둘 중 하나는 대기해야 함
Process A가 임계 구역에 진입하면, Process B는 대기해야 함
Race condition
임계 구역은 두 개 이상의 프로세스가 동시에 실행되면 안되는 영역이지만 여러 프로세스가 동시에 다발적으로 실행하여 자원의 일관성이 깨지는 경우
Race condition의 근본적인 이유
고급언어(C/C++, Python) → 저급언어로 변환되며 코드가 늘어날 수 있음
이 때 context switching이 발생하면 예상치 못한 결과를 얻을 수 있음
상호 배제를 위한 동기화는 이런 일이 발생하지 않도록 두 개 이상의 프로세스가 임계 구역에 동시에 접근하지 못하도록 관리하는 것을 의미하며 운영체제는 3가지 원칙하에 해결
progress: 임계 구역에 어떤 프로세스도 진입하지 않았다면 임계 구역에 진입하고자 하는 프로세스는 들어갈 수 있어야 함
Mutual exclusion : 한 프로세스가 임계 구역에 진입했다면 다른 프로세스는 임게 구역에 들어올 수 없음
bounded waiting: 한 프로세스가 임계 구역에 진입하고 싶다면 그 프로세스는 언젠가는 임게 구역에 들어올 수 있어야 함 (무한정 대기 X)
동기화 기법
동기화를 위한 대표적인 도구 3가지
뮤텍스 락
세마포
모니터
뮤텍스 락(Mutex lock)
Mutual EXclusion lock, 상호 배제를 위한 동기화 도구로 기능을 코드로 구현한 것
매우 단순한 형태는 하나의 전역 변수와 두개의 함수
자물쇠 역할: 프로세스들이 공유하는 전역 변수 lock
임계구역을 잠그는 역할: acquire 함수
임계 구역의 잠금을 해제하는 역할: release 함수
acquire 함수
프로세스가 임계 구역에 진입하기 전에 호출하는 함수
임계 구역이 잠겨있으면 열릴 때까지(lock이 false가 될 때까지) 임계 구역을 반복적으로 확인
임계 구역이 열려있으면 임계 구역을 잠그는 함수
release 함수
임계 구역에서의 작업이 끝나고 호출하는 함수
acquire(){
while (lock ==true) # 반복해서 확인하는 대기 방식을 busy wait이라고 함
; # 임계 구역이 잠겨 있는지 반복적으로 확인
lock = true; # 만약 임계 구역이 잠겨 있지 않다면 임계 구역 잠금
}
release() {
lock = false; # 임계 구역 작업이 끝나서 잠금 해제
}
acquire();
// 임계구역 (ex) '총합' 변수 접근)
release();
세마포(Semaphore)
사전적 의미: 수기 신호
뮤텍스 락과 비슷하지만 좀 더 일반화된 방식의 동기화 도구
공유 자원이 여러개 있는 상황에서도 적용이 가능한 동기화 도구
단순한 형태로 하나의 변수와 두개의 함수로 구현가능
임계 구역에 진입할 수 있는 프로세스의 개수를 나타내는 전역 변수 S
임계 구역에 들어가도 좋은지 기다려야 할지를 아려주는 wait 함수
이제 가도 좋다는 신호를 주는 signal 함수
wait()
{
while( S <= 0) # 임계 구역에 진입할 수 있는 프로세스가 0개 이하라면
; # 사용할 수 있는 자원이 있는지 반복적으로 확인하고
S--; # 진입할 수 있는 프로세스 개수가 1개 이상이면 S를 1 감소시키고 진입
}
signal()
{
S++; # 임계 구역에서의 작업을 마친 뒤 S를 1 증가 시킴
}
wait()
// 임계 구역
signal()
예시
세 개의 프로세스 P1, P2, P3가 2개의 공유 자원(S=2)에 순서대로 접근한다고 가정하면,
프로세스 P1 wait 호출, S는 2→1로 감소
프로세스 P2 wait 호출, S는 1→0으로 감소
프로세스 P3 wait 호출, S는 현재 0이므로 무한히 반복하며 S확인
프로세스 P1 임계 구역 작업 종료, signal 호출, S는 0→1
프로세스 P3에서 S가 1이 됨을 확인하고 S 1→0 만들고 임계 구역 진입
위의 3 과정에서 busy wait 반복하며 확인하며 CPU 사이클이 낭비됨 * busy wait: 쉴새없이 반복하며 확인해보며 기다리는 과정
해결 방법
사용할 수 있는 자원이 없을 경우 대기 상태로 만듦 (해당 프로세스의 PCB를 waiting queue에 삽입)
사용할 수 있는 자원이 생겼을 경우 대기 큐의 프로세스를 준비 상태로 만듦 (해당 프로세스의 PCB를 waiting queue → ready queue 로 이동)
wait()
{
S--;
if ( S < 0) {
add this process to waiting queue;
sleep();
}
}
signal()
{
S++;
if (S<=0)
# remove a process p from waiting Queue;
wakeup(p); # waiting queue -> ready queue
}
}
프로세스 4개이고, 공유 자원이 2개라고 가정해보면,
프로세스 P1 wait 호출, S는 2→1로 감소
프로세스 P2 wait 호출, S는 1→0으로 감소
프로세스 P3 wait 호출, S는 0→-1이 되고 waiting queue로 이동
프로세스 P4 wait 호출, S는 -1→-2가 되고 waiting queue로 이동
프로세스 P1 임계 구역 작업 종료, signal 호출, S는 -2→-1이 되고 PCB를 waiting queue → ready queue로 이동
프로세스 P2 임계 구역 작업 종료, signal 호출, S는 -1→0이 되고, PCB를 waiting queue → ready queue로 이동
세마포는 프로세스의 실행순서 동기화도 지원
변수 S를 0으로 두고 먼저 실행할 프로세스 뒤에 signal 함수, 다음에 실행할 프로세스 앞에 wait함수를 붙이면 됨
P1
P2
wait()
// 임계 구역
// 임계 구역
signal()
위의 경우 프로세스의 실행 순서에 상곤없이 P1→P2 순으로 임계 구역에 진입
모니터(Monitor)
세마포는 훌륭한 프로세스 동기화 도구지만 매번 임계 구역 앞뒤로 wait & signal 함수를 명시해야 함
모니터는 사용자(개발자)가 다루기에 편한 동기화 도구
상호 배제를 위한 동기화
공유자원과 공유 자원에 접근하기 위한 인터페이스를 묶어서 관리
프로세스는 반드시 인터페이스를 통해서만 공유 자원에 접근하도록 함
공유자원에는 하나의 프로세스만 접근
실행 순서 제어를 위한 동기화
내부적으로 조건 변수(condition variable)를 이용
프로세스나 스레드의 실행 순서를 제어하기 위해 사용하는 특별한 변수
조건 변수별 queue가 있고 이를 통해 실행 순서를 결정할 수 있음
조건 변수로 wait과 signal 연산을 수행할 수 있음
조건변수.wait(): 대기 상태로 변경, 조건 변수에 대한 큐에 삽입
조건변수.signal(): wait()으로 대기 상태로 접어든 조건 변수를 실행상태로 변경
모니터 안에는 하나의 프로세스만 있을 수 있음
wait()을 호출했던 프로세스는 signal()을 호출한 프로세스가 모니터를 떠난 뒤에 수행을 재개
signal()을 호출한 프로세스의 실행을 일시 중단하고 자신이 실행된 뒤 다시 signal()을 호출한 프로세스의 수행을 재개