정리

  • 프로세스는 자원을 따로 복사해 무겁고 안전하지만 비용이 크다.
  • 쓰레드는 자원을 공유해 가볍고 빠르지만 공유 데이터 관리가 필요하다.
    • 생성 비용이 낮고 응답성이 좋아 병렬 처리와 I/O 중첩에 유리하다.
  • ULT는 빠르지만 블록 문제가 있고, KLT는 진정한 병렬성을 제공하지만 오버헤드가 있다.
  • 멀티코어에서의 성능 향상은 Amdahl의 법칙과 오버헤드에 의해 제한된다.

Intro.

1. 모듈 간 통신 방법

프로젝트를 모듈 단위로 설계할 때, 모듈의 위치에 따라 통신 방법이 달라진다.

  1. 하나의 프로세스 내부

    • 함수를 직접 호출
    • (메모리를 공유하여 별도의 통신 기법 불필요)
  2. (같은 컴퓨터) 여러 프로세스

    • 프로세스는 메모리를 공유하지 않으므로 OS가 제공하는 IPC(Inter-Process Communication) 필요
      • Shared Memory: 메모리 영역 공유 → 빠르지만 동기화 문제
      • Message Passing: 메시지 송수신 → 안전하고 단순
      • Semaphore: 통신이 아닌 동기화 도구 (공유 자원 접근 제어)
      • Pipe: 파일처럼 동작하는 단방향 채널
  3. 다른 노드(컴퓨터)에 있는 프로세스

    • OS가 IPC 중재 불가 → 네트워크 API 사용
      • Socket: 저수준 통신, IP+포트 연결, TCP/UDP 선택
      • RPC: 원격 함수를 로컬 함수처럼 호출 (내부적으로 소켓 사용)

2. 소프트웨어 개발 3요소

  1. 기능 정확도 (입문 단계)
  2. 성능 최적화 (경쟁력)
  3. Secure Coding

3. 프로세스 vs 스레드

  • 멀티 프로세스 (fork() + exec())

    • 각 프로세스가 독립된 메모리 공간 보유
    • 통신 시 IPC 필요 → 무거움
  • 멀티 스레드 (pthread_create())

    • 같은 프로세스 내에서 메모리 공유
    • Process Context 오버헤드 없음 → 가볍고 빠름
  • 핵심 포인트

    • 한 프로세스 안에서 동시 작업이 필요할 때,
    • 무거운 멀티 프로세스 대신 메모리를 공유하는 스레드를 사용하면 효율적이다.

1. 프로세스와 쓰레드

  • 프로세스

    • 실행 중인 프로그램(a program in execution)이다.
  • 프로세스는 두 가지 역할을 동시에 가진다.

    1. 메모리·파일·IPC·시그널 같은 자원을 소유하는 자원 소유권(resource ownership) 역할
    2. 다른 하나는 CPU 레지스터와 스케줄링 정보를 바탕으로 실제 실행되는 수행/스케줄링 역할
  • 현대 운영체제는 이 두 역할을 분리해서,

    1. 자원을 담는 그릇은 태스크(task),
    2. 실제 제어 흐름은 쓰레드(thread)로 본다. 그래서 쓰레드를 경량 프로세스(lightweight process)라고 부르기도 한다.

1.1. 프로세스 이미지

  • 프로세스 이미지

    • 위에서부터 PCB, 텍스트(코드), 데이터(전역변수), 힙(동적 할당), free area, 스택(지역변수·매개변수) 순으로 구성된다.
    • 힙은 위로, 스택은 아래에서 위로 자라며 가운데 빈 공간을 공유한다.
  • 스레드별로 저장해야 하는 정보

    1. 스레드별 스택
      • 함수 호출, 지역 변수, 매개변수 등
    2. 스레드 제어 블록(TCB; Thread Control Block)
      • 스레드 ID, PC (Program Counter), 레지스터 값 등

1.2. 쓰레드 모델

  • 스레드 생성 및 처리 흐름

    1. creating program: 메인 프로그램에서 이벤트(클라이언트 연결 등) 감지
    2. pthread_create(fn()): 해당 이벤트를 처리할 새 스레드 생성
    3. created thread: 스레드 생성 완료
    4. processfd(): 생성된 스레드 내에서 소켓(File Descriptor)을 실제로 처리
  • 쓰레드 모델은 4가지로 구분된다.

    1. 단일 프로세스/단일 쓰레드
    2. 단일 프로세스/멀티 쓰레드
    3. 멀티 프로세스/프로세스당 단일 쓰레드
    4. 멀티 프로세스/프로세스당 멀티 쓰레드

  • 일반 함수 호출 vs. 쓰레드 함수 호출의 차이가 핵심이다.

    1. 일반 함수 호출은 호출한 쪽이 멈추고 함수가 끝나야 돌아온다. (단일 실행 흐름)
    2. pthread_create()로 만든 쓰레드는 생성 즉시 별개의 실행 흐름으로 갈라져 동시에 진행된다.
  • 멀티쓰레딩 환경의 공유 관계:

    • 프로세스(태스크) 단위로 공유되는 것: 가상 주소 공간, 파일, I/O 자원, 다른 프로세스에 대한 접근 제어
    • 쓰레드마다 별개로 갖는 것: 실행 상태(수행/준비/블록), CPU 레지스터 문맥, 실행 스택, 지역변수 저장 공간
    • 한 프로세스 내 모든 쓰레드는 코드·데이터·파일을 공유하지만, 레지스터와 스택은 각자 따로 가진다. (단일 쓰레드 모델은 이 모든 것을 하나만 가짐)

cf. Creation Overhead

방식비용(100개 작업) 총 비용
일반1000 / 1 / 5001500 × 100 = 150,000
쓰레드1000 / (100 / 1 / 50)1,000 + 150 × 100 = 15,100
5개 쓰레드 풀1000 / (100 / 1 / 50)1,000 + 5 × 150 + 100 × 1 = 1,850

(5개 쓰레드 풀, 100개 작업 기준) → 생성/종료 비용이 0에 수렴

  1. 비용 비교 (생성/종료 오버헤드)

    • 프로세스 방식: 1개 생성/종료 비용이 1,500이며, 100번 반복 시 총 150,000 소모
    • 쓰레드 방식: 프로세스 초기 비용(1,000) + 쓰레드 생성/종료(150) × 100 = 15,100
    • 결론: 쓰레드가 프로세스보다 약 10배 저렴하지만, 여전히 매번 생성/종료하는 비용이 누적됨
  2. 풀(Pool)의 등장 배경

    • “100번의 작업 동안 생성/종료를 아예 하지 않는다면?”이라는 질문에서 출발
    • 미리 만들어 두고 재사용하면 생성/종료 비용을 거의 제거할 수 있음
  3. 풀의 종류

    • 프로세스 풀(Process-pool): 독립된 메모리 공간을 가진 프로세스들을 미리 생성
    • 쓰레드 풀(Thread-pool): 하나의 프로세스 내에서 자원을 공유하는 쓰레드들을 미리 생성
  4. 풀의 동작 3단계

    1. 초기화: 시작 시 미리 생성 (생성 비용은 이때 한 번만)
    2. 작업 할당: 들어온 일을 Idle 상태의 자원에 배정
    3. 반환: 작업 완료 후 종료하지 않고 풀에 반납 → 재사용

1.3. 쓰레드의 장점

  • 쓰레드의 장점:

    1. 프로세스보다 생성/종료 비용이 훨씬 저렴함
    2. 병렬처리에 적합 → 멀티프로세서 효율 활용, 병행 서버 구현 (요청 수신 쓰레드 + 응답 쓰레드)
    3. 응답시간 향상 → I/O 작업과 계산 작업을 동시 진행 (워드 프로세서, 스프레드시트 등)
  • 생성 비용 차이는 표(12쪽)에서 극적으로 드러난다.

    • 5만 번 생성 기준으로 fork()는 수십~100초대인데
    • pthread_create()는 1~2초 수준으로, 쓰레드가 훨씬 가볍다.

2. 프로세스/쓰레드 생성 함수

2.1. fork() 시스템 호출

  • fork() (프로세스 생성):
    • 한 번 호출되지만 부모와 자식 양쪽에서 반환된다.
    • 부모에게는 자식의 PID(0이 아닌 값), 자식에게는 0이 반환되므로 이 반환값으로 분기한다.
if (pid == 0) { /* 자식 코드 */ }
else { /* 부모 코드 */ }

(1) 예제

/* test_fork2.c: 같은 변수 접근 */
#include <sys/types.h>
#include <unistd.h>
// 함수 원형: pid_t fork(void);
 
int glob = 6;
char buf[] = "a write to stdout\n";
 
int main(void) {
	int var = 88;
	pid_t pid;
	
	if (write(STDOUT_FILENO, buf, sizeof(buf)) != sizeof(buf)) {
		perror(“write error”);
		exit(1);
	}
	printf("before fork\n"); /* we don't flush stdout */
	
	if ( (pid = fork()) < 0) {
		perror(“fork error”);
		exit(1);
	} else if (pid == 0) { // **자식 프로세스**: 0 반환
		/* modify variables */
		glob++;
		var++;
	} else { // **부모 프로세스**: 자식 프로세스의 PID 반환 (non-zero)
		sleep(2);
	}
	printf("pid = %d, glob = %d, var = %d\n", getpid(), glob, var
	// 부모는 glob=6, var=88
	// 자식은 glob=7, var=89
	
	exit(0);
}
  • 예제(test_fork2.c)의 핵심은
    • fork 후 부모·자식이 변수를 복사해서 따로 가진다는 점이다.
    • 자식이 glob++; var++를 해도 부모 값은 그대로이다.
    • 실행 결과에서 자식은 glob=7, var=89, 부모는 glob=6, var=88로 서로 독립적임이 확인된다.

2.2. pthread_create()

  • pthread (POSIX Thread): IEEE POSIX 1003.1c 표준

    • pthread.h를 include하고 컴파일 시 -lpthread 옵션을 붙인다. (gcc -o test test.c -lpthread)
    • 리눅스의 pthread는 clone() 시스템 콜로 구현된 커널 수준 쓰레드이다.
    • 생성은 pthread_create(&tid, NULL, 함수, 인자), 종료는 pthread_exit(NULL)로 한다.
  • 여기서 fork와 결정적으로 다른 점이 나온다.

    • pthread 예제에서는 전역변수 glob모든 쓰레드가 공유하므로 6→7→8→9→10→11로 누적 증가한다.
    • 반면 지역변수 var는 각 쓰레드 스택에 따로 있어 항상 89로 동일하다.
    • 앞의 fork 예제와 비교하면 “프로세스=복사 / 쓰레드=공유”의 차이가 명확해진다.

(1) 예제

#include <pthread.h> // gcc test.c -o test -lpthread
 
#define NUM_THREADS 5
 
void *PrintHello(void *threadid) {
	printf("Hello World! It’s me, thread #%ld \n", threadid);
	pthread_exit(NULL);
}
 
int main (int argc, char *argv[]) {
	pthread_t threads[NUM_THREADS];
	int rc, t;
	
	for (t=0;t < NUM_THREADS;t++) {
		printf("In main: creating thread %d\n", t);
		
		// 쓰레드 생성 (&thread, NULL, 함수명, 인자)
		rc = pthread_create(&threads[t], NULL, PrintHello, (void *)t);
		
		if (rc) {
			printf("ERROR; return code from pthread_create() is %d\n", rc);
			exit(-1);
		}
	}
	pthread_exit(NULL); // 쓰레드 종료
}

2.3. 활용 사례

  • 웹 서버

    • Dispatcher thread가 요청을 받아 Worker thread에 분배하고,
    • Worker는 캐시 확인 후 페이지를 반환
  • RPC(원격 프로시저 호출)

    • 여러 쓰레드가 각각 다른 서버에 RPC 요청을 보내 응답 대기 시간을 중첩

cf. TCP 소켓 통신의 서버/클라이언트 흐름 예

  • 윈도우 서버 프로그래밍 발전사
    1. 초기: WSAAsyncSelect (메시지 기반)
    2. 중기: WSAAsyncEvent (이벤트 신호 기반)
    3. 발전: Overlapped I/O (비동기 결과 통보)
      • Event 방식 (I), Completion Routine 방식 (II)
    4. 완성: IOCP(I/O Completion Port) = Thread Pool + 비동기 I/O

흐름 과정에서 bind()가 명시적/암묵적으로 어떻게 호출되는지

  1. 서버의 accept()는 내부적으로 새로운 소켓(nsd)을 만들어 클라이언트와 1:1 통신을 담당시킨다.
  2. 클라이언트는 명시적으로 bind()를 호출하지 않지만, connect() 내부에서 자동으로 임시 포트가 bind된다.
// ===== Server =====
sd = socket(AF_INET, SOCK_STREAM, 0);		// TCP 소켓 생성 (listening socket)
bind(sd, IP, Port);							// 서버의 IP/Port에 결합
listen(sd, backlog);						// 연결 요청 대기 큐 생성
 
while (1) {
	nsd = accept(sd, &client_addr, &len);	// 클라이언트 연결 수락
	// 내부적으로 새 소켓 nsd 생성:
	//     nsd = socket() → 통신 전용 소켓
	//     bind(nsd, server_IP, same_Port) → 서버 주소에 자동 결합
	//     (즉, listening socket과 동일한 포트를 공유하되,
	//     4-tuple [src IP/Port, dst IP/Port]로 구분됨)
	
	if (fork() == 0) {						// 또는 pthread_create()
		close(sd);							// 자식은 listening socket 불필요
		svc(nsd);							// 클라이언트 서비스 처리
		close(nsd);
		exit(0);
	}
	close(nsd);								// 부모는 통신 소켓 불필요, 다시 accept 대기
	}
// ===== Client =====
sd = socket(AF_INET, SOCK_STREAM, 0);		// TCP 소켓 생성
connect(sd, server_IP, server_Port);		// 서버에 연결 요청
// 실질적으로 connect() 안에서 자동으로:
//     bind(sd, INADDR_ANY, 0) → OS가 임시 포트(ephemeral port) 자동 할당
//     (클라이언트는 굳이 자기 포트를 지정할 필요가 없으므로)
 
svc(sd);									// 서버와 통신
close(sd);
  • 서버 측: accept()의 동작

    • sd(listening socket)는 연결을 받기만 하는 전용 소켓
    • accept()는 새 연결이 들어올 때마다 새로운 소켓 nsd를 생성하여 반환
    • nsd는 해당 클라이언트와의 1:1 통신 전용 소켓
    • 즉, 같은 포트(예: 80)에 여러 클라이언트가 붙어도 각각 별도의 nsd로 구분됨 (4-tuple로 식별)
  • 클라이언트 측: 암묵적 bind

    • 클라이언트는 자신의 포트 번호를 신경 쓸 필요가 없음
    • connect() 호출 시 OS가 사용 가능한 임시 포트(ephemeral port)를 자동 할당하여 내부적으로 bind
    • 그래서 클라이언트 코드에는 명시적 bind()가 없음
  • 멀티쓰레딩/멀티프로세싱과의 연결

    • svc(nsd)를 처리하는 부분에서 보통 fork() 또는 pthread_create()를 사용
    • 한 서버가 동시에 여러 클라이언트를 처리할 수 있는 이유 = 새 nsd마다 새로운 쓰레드/프로세스 배정
    • 여기서 앞서 정리한 쓰레드 풀(Thread Pool) 개념이 활용됨 (매번 생성/종료 대신 미리 만들어둔 풀에서 꺼내 씀)

3. 쓰레드의 유형

구분사용자 수준 쓰레드(ULT)커널 수준 쓰레드(KLT)
관리 주체응용 프로그램이 쓰레드 라이브러리를 통해커널이 직접
커널 인지 여부커널은 쓰레드 존재를 모름커널이 문맥·스케줄링 관리
cthread, pthreadWindows

3.1. 사용자 수준 쓰레드 (ULT)

  • 사용자 수준 쓰레드 장점:

    • 쓰레드 전환 시 커널 모드 전환이 필요 없어 오버헤드 절약,
    • 응용에 맞는 스케줄링 가능,
    • 모든 OS에서 동작
  • 사용자 수준 쓰레드 단점:

    • 한 쓰레드가 블로킹 시스템 호출을 하면 같은 프로세스의 모든 쓰레드가 함께 블록됨,
    • 멀티프로세서 이점을 살리지 못함,
    • 커널 루틴 자체는 멀티쓰레딩 불가
  • 성능 표(30쪽)를 보면

    • Null Fork 연산이 ULT 34, KLT 948, 프로세스 11,300(μs)로,
    • 사용자 수준 쓰레드가 가장 빠르고 프로세스 생성이 압도적으로 느리다.

3.2. 커널 수준 쓰레드 (KLT)

  • 장점:

    • 다중처리기 활용 가능
  • 단점:

    • 모드 전환 오버헤드 발생

3.3. 결합 방식

  • 결합 방식 (Combined)

    • ULT와 KLT를 혼합한 방식
    • 예: Solaris
  • 단점:

    • 구현 복잡

3.4. 쓰레드와 프로세스의 관계

  • 1:1: 대부분의 UNIX
  • M:1: Windows NT, Solaris, Linux, MACH (한 프로세스에 여러 쓰레드)
  • 1:M: Ra(Clouds), Emerald (쓰레드가 프로세스 간 이동 가능)
  • M:N: TRIX

4. 멀티코어와 멀티쓰레딩

  • Amdahl의 법칙으로 병렬화 성능 한계를 설명한다.

    • 여기서 f는 병렬화 가능한 코드 비율, (1−f)는 순차적으로만 실행되는 코드 비율, N은 프로세서 개수이다.
    • 순차 부분(1−f)이 전체 속도향상의 상한을 결정하기 때문에, 병렬 비율이 낮으면 코어를 아무리 늘려도 효과가 제한된다.
  • 또한 실제로는 멀티프로세서 간 통신, 작업 분배, 캐시 일관성 유지 같은 오버헤드 때문에, 코어 수를 늘릴수록 속도향상이 계속 오르지 않고 어느 지점에서 정점을 찍고 오히려 꺾인다. (34쪽 그래프 (b))


cf. Linux Process Context

  • 프로세스 문맥이란
    • 운영체제가 프로세스를 관리하고 실행하는 데 필요한 모든 정보로,
    • 시스템 문맥 · 하드웨어 문맥 · 메모리 문맥 세 영역으로 구성된다.

1. 시스템 문맥 (System Context)

커널이 프로세스 관리를 위해 유지하는 자료구조들이다. Task Structure가 중심이 되어 다른 구조들을 포인터로 연결한다.

  • Task Structure: 프로세스 핵심 정보를 담고 다른 자료구조를 가리키는 중심 구조체
  • File Table & fd: 열린 파일 관리 (task structure → fd 배열 → file table 참조)
  • Segment Table: 메모리 세그먼트 정보, page table을 가리킴
  • Page Table: 가상 주소 → 물리 주소 변환 정보

2. 하드웨어 문맥 (Hardware Context)

CPU 레지스터 값을 저장하는 영역으로 **TSS(Task State Segment)**라 불린다. 문맥 교환 시 실행 상태를 보존·복원하며, Task Structure와 양방향으로 연결된다.

  • 주요 레지스터는
    • eip(다음 명령어 주소), sp(스택 포인터), eflags(CPU 상태 플래그), eax(범용 레지스터), cs(코드 세그먼트)이다.

3. 메모리 문맥 (Memory Context)

  • 프로세스가 실제 사용하는 메모리 공간이다.
    • Memory(주기억장치): page table로 매핑된 물리 메모리, 코드·데이터 적재
    • Disk(swap / a.out): 메모리에 없는 부분이 보관되는 곳, 필요 시 메모리로 적재