[Week6] CSAPP 12

안나경·2024년 2월 27일

크래프톤정글

목록 보기
38/57

12 동시성 프로그래밍

논리 제어 흐름이
시간적으로 중첩되는 것을
동시적, 동시성 concurrency이라고 한다.

동시성은 시스템이 실행 중 외에도
사용자의, 응용프로그램 수준에서도 유용.

  • 느린 I/O 디바이스 접근 처리
    디스크 같은 입출력을 기다리며 다른 프로그램 실행.
  • 사람이 원하는 상호작용
    문서 출력 중에도 윈도우 크기 조정을 하는 등.
  • 작업 지연으로 시간 지연 줄이기.
    수많은 개별 작업을 항상 하는것보단 작업을 나중에 해서 좀더 효율적 작업 진행.
  • 다수의 네트워크 클라이언트 처리.
  • 멀티 코어 머신에서 병렬로 계산.

현대 운영 체제에서
동시성 프로그램을 만들기 위해
세 개의 기본 접근 방법을 제시.

  • 프로세스
    논리적 흐름을 커널이 스케줄하고 관리한다.
    프로세스는 별도의 가상 공간을 가지므로
    서로 통신하기 원하는 흐름의 경우
    명시적 프로세스간 통신, interprocess communication, IPC 메커니즘을 사용한다.
  • I/O 다중화.
    응용들이 자신의 논리 흐름을
    한개의 프로세스 컨텍스트 내에서 스케줄 한다.
    파일 식별자에 도착하는 데이터로, 메인 프로그램이 하나의 상태에서 다른 상태로 전환하는 상태 머신으로 모델할 수 있다.
    프로그램이 한 개의 프로세스라, 모든 흐름이 동일한 주소공간을 공유한다.
  • 쓰레드 Therad
    한 개의 프로세스 컨텍스트에서 돌아가는 논리흐름을, 커널이 스케줄한다. 쓰레드는 두 개 방식의 하이브리드로 생각할 수 있다.

12.1 프로세스를 사용한 동시성 프로그래밍

가장 간단한 방법이
프로세스 사용이다.

부모에서 클라이언트 연결 요청을 수락하고,
새로운 자식 프로세스를 생성해 각각의 새로운 클라이언트를 서비스한다.

ex)

[시작]

부모 - 듣기 <- cli 1

[연결 중...]

부모 - 듣기
ㅡㅡㅡ- 연결 - cli 1

[fork 후...]

부모 - 듣기
ㅡㅡㅡ- 연결 - cli 1
자식 - 듣기
ㅡㅡㅡ- 연결 - cli 1

[가공 중...]

부모 - 듣기
ㅡㅡㅡ-x
자식 -x
ㅡㅡㅡ- 연결 - cli 1

...

닫지 않으면,
열려있는 동안 동일한 파일 엔트리를 가리키기때문에
나중에 자식이 닫는다고 해도 부모에서 계속 열고 있어
반환되지 않으면서 메모리 누수가 생기므로 주의하자.

12.1.1 프로세스 기반 동시성 서버

프로세스 기반 동시성 서버를 구현한
echo 서버 코드가 있다.

이 때의 중요한 몇 가지 사항이 있다.

  • 서버들은 대개 장시간 동안 돌아가므로 자식을 청소하는 SIGCHLD 핸들러를 포함해야한다.
    SIGCHLD 시그널들은 SIGHLD가 돌고 있는 동안 블록 되고,
    리눅스 시그널은 큐에 들어가지 않으므로 SIGHLD 핸들러는 다수의 좀비 자식을 청소할 준비를 해야한다.
    (아마 SIGCHLD들은 SIGHLD 핸들러가 데려갈때까지 블록... 즉, 멈춰있다는 거같다.)
  • 부모와 자식은 자신의 connfd 사본을 닫아야한다.
    앞에서 말했듯 부모쪽이 특히 중요하다.
  • 소켓의 파일 테이블 엔트리 내 참조 횟수때문에 클라이언트로의 연결은 부모, 자식 connfd 사본이 모두 닫힐때까지 종료되지 않는다.

...

SIGCHLD는 자식 프로세스가
자신이 종료, 중단되었음을 부모 프로세스에게 알리기 위해 사용.

waitpid 함수는 자식 프로세스의 종료 상태를 기다리는 함수로,
-1은 모든 자식 프로세스를 대상으로 한다는것을,
WNOHANG 플래그는 자식 프로세스가 종료되지 않아도 반환시킨다.
(그러니까 저걸 안 하면 걔가 종료될때까지 무한 대기타지만
이 플래그를 쓰면 일단 종료 안했다고 보고하고 끝나는듯)

그래서 시그널 핸들러 함수는
모든 종료된 자식 프로세스에 대해
waitpid를 호출, 반환값이 0보다 크다면 루프를 계속 반복.

waitpid 함수의 가운데 0은
자식 프로세스의 상태 정보를 저장하는 포인터다.
0을 넣으면 저장하지 않고 넘어간다는 뜻이다.

#include "csapp.h"
void echo(int connfd);

//SIGCHLD 시그널 발생시 호출됨.
void sigchld_handler(int sig)
{
	while(waitpid(-1, 0, WNOHANG) > 0)
    	;
    return;
}


int main(int argc, char **argv)
{
	int listenfd, connfd, port;
    socklen_t clientlen=sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;
    char client_hostname[MAXLINE], client_port[MAXLINE];
 
 	//인자 2개만 받을게
    if(argc != 2){
    	fprintf(stderr, "usage: %s <port>\n", argv[0]);
    	exit(0);
    }
    // 문자열을 정수로 변경.
    port = atoi([argv[1]);
    
    //보디가드 시그널핸들러 불러놓음
    Signal(SIGCHLD, sigchld_handler);
   
    listenfd = Open_listenfd(port);
    
    while(1){
    	connfd = Accept(listenfd, (SA *)&clientaddr, 
        			&clientlen);
        // 자식 프로세스는 듣기 닫고
        // echo를 수행한 뒤 닫힐 거야.
    	if(Fork() == 0) {
        	Close(listenfd);
            echo(connfd);
            Close(connfd);
            exit(0);
        }
        // 연결 시켰으니까 부모는 연결 닫을게
    	Close(connfd);
    }    
}

12.1.2 프로세스의 장단점

프로세스는,
부모 자식 사이에
파일 테이블은 공유되고, 사용자 주소 공간은 공유되지 않는다.

분리된 주소 공간 사용은 장점이자 단점이다.
우연히 다른 프로세스의 가상 메모리를 쓰는 오류를 피하지만,
프로세스가 상태 정보를 공유하는게 어렵다.

그때는 IPC 메커니즘을 사용해야한다.
또 다른 단점은, 프로레스 제어와 IPC오버헤드가 크기때문에
더 느려지는 경향이 있다.

12.2 I/O 다중화를 이용한 동시성 프로그래밍

만약...
프로세스 내에서
(1) 연결 요청을 하는 네크워크 클라이언트
(2) 키보드에 타이핑 하는 클라이언트

둘 중 무슨 이벤트를 먼저 기다려야하는가?
무엇이든 이상적이지 않다.

이 딜레마에 대한 해결책이 I/O 다중화, I/O multiplexing라 부르는 기술이다.

기본 아이디어는
select 함수를 사용해
커널에게 이 프로세스를 정지할 것을 요구,
한 개 이상의 I/O 이벤트 발생 시에만
응용에게 제어를 돌려준다.

ex) 어떤 식별자라도 읽기 준비가 되면 리턴.
어떤 식별자라도 쓰기 준비가 되면 리턴.
n초간 I/O 이벤트를 기다리다 경과했다면 타임아웃 처리.

우리는 한 가지만 한다 : 식별자 집합이 읽기 준비가 될때까지 기다린다.

#include <unistd.h>
#include <sys/types.h>

int select(int n, fd_set *fdset, NULL, NULL, NULL);
// 준비된 디스크럽터를 반환하거나, -1 에러 반환.

FD_ZERO(fd_set *fdset); // fdset 모든 비트 청소
FD_CLR(int fd, fd_set *fdset); // fdset의 fd 청소
FD_SET(int fd, fd_set *fdset); // fdset의 fd 상태 전환
FD_ISSET(int fd, fd_set *fdset); // 이 fd fdset에 있어?

select 함수는 fd_set 타입 집합을 관리하는데,
이를 식별자 집합, descriptor sets 이라고 한다.

식별자 집합을 길이 n을 갖는 비트 벡터로 간주한다.
(2.1절에서 나온다는군..)

b0, b1, b2.....b(n-1)

각각 비트 b(k)는 식별자 k에 대응된다.
k는 b(k) = 1인 경우에만
식별자 집합의 멤버가 된다.

이 식별자 집합으로
세 개의 일을 할 수 있도록 허용한다.

  • 식별자 할당
  • 이 타입의 변수 한 개를 다른 변수에 할당
  • 이들을 FD... 매크로를 사용해 수정하고 조사.

(흠 대체 뭐하는건지 모르겠군)

select 함수는 두개의 입력을 사용한다

  • 읽기 집합 read_set 이라 부르는 식별자 집합 fd_set
  • 읽기 집합의 크기 fd_set
    (실제로는 식별자 집합의 최대 크기)

흠 진짜 뭔소리야

#include "csapp.h"
void echo(int connfd);
void command(void);

int main(int argc, char **argv)
{
	int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    fd_set read_set, ready_set;
    
    if (argc != 2) {
    	fprintf(stderr, "usage: %s <port> \n", argv[0]);
        exit(0);
    }
    
    listenfd = Open_listenfd(argv[1]);
    
    // 식별자 집합 세팅 : 빈 집합 만들고 두개 1표시
    FD_ZERO(&read_set);
    FD_SET(STDIN_FILENO, &read_set);
    FD_SET(listenfd, &read_set);
    
    //ready_set에서 Select가 신호 들어올때마다 다르게 처리하게 함.
    while(1) {
    	ready_set = read_set;
        Select(listenfd+1, &ready_set, NULL, NULL, NULL);
        if (FD_ISSET(STDIN_FILENO, &ready_set))
        	command();
        if (FD_ISSET(listenfd, &ready_set)){
        	clientlen = sizeof(struct sockadder_storage);
            connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
            echo(connfd);
            Close(connfd);
            }
        } 
}
    
void command(void) {
	char buf[MAXLINE]
    if (!Fgets(buf, MAXLINE, stdin))
    	exit(0);
    printf("%s", buf);
    
}

흐음
아마

식별자 집합으로 집합 만듦(read_set)
그리고 그것중에 확인할 집합을 변수만 다르게 새로 붙여서(ready_set)
FD매크로로 세팅하는군.

굳이 집합을 두개로 만드는 이유는
혹시 모를 초기 집합을 그냥 저장해두려고 하는거같다.
(나중에 쓸 수도 있으니까?)

...
이 함수는 select의 좋은 예제이나
클라이언트가 자신의 소켓을 닫을때까지
입력 라인을 계속 echo한다.

서버에서 이 연결이 끝날때까지
별도의 명령을 처리하지 못한다는 점이다.

(서버루프를 통과할때마다 1개의 에코를 하라고 하는데
잘 모르겠군.)

12.2.1 I/O 다중화에 기초한 동시성 이벤트 기반 서버

동시성 이벤트기반 프로그램 기초로 I/O 다중화 개념을 쓸 수 있다.

아이디어는
논리 흐름을 상태 머신으로 모델링하는 것이다.

비공식적으로,
상태 머신은 상태(states), 입력 이벤트(input evenets), 그리고
[상태]와 [입력 이벤트 ->상태] 를 매핑하는 전환(transition)의 집합이다.

각각의 transition은
(input states, input event) <=> transition
이 된다.
ex)
state : d(k) 라는 fd에서 읽기 준비가 되기를 기다린다.
input event : d(k)에서 읽기 준비가 되었다.
transition : d(k)에서 text를 읽는다.

서버는 Select 함수로
입력 이벤트 발생을 감지하여 전환한다.

아래는
I/O 다중화에 기초한 동시성 이벤트 기반 서버
예제 코드다.

동작하고 있는 클라이언트 집합은 pool 구조체에서
관리된다.

#include "csapp.h"

typedef struct {
	int maxfd;
    fd_set read_set;
    fd_set ready_set;
    int nready;
    int maxi;
    int clientfd[FD_SETSIZE];
    rio_t clientrio[FD_SETSIZE];
    } pool;
    
 int byte_cnt = 0;
 
 int main(int argc, char **argv)
 {
 	int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    static pool pool;
    
    if (argc != 2) {
    	fprintf(stderr, "usage: %s <port>\n", argv[0]);
        }
    
    listenfd = Open_listenfd(argv[1]);
    init_pool(listenfd, &pool);
    
    
    while(1) {
    	// 집합 만드는데 이제 pool이라는 구조체에서 진행
    	pool.ready_set = pool.read_set;
        pool.nready = Select(pool.maxfd+1, &pool.ready_set, NULL, NULL, NULL);
        
        //listen에서 신호 오면 연결 시켜주고 pool에 추가해
        if (FD_ISSET(listenfd, &pool.ready_set)){
        	clientlen = sizeof(struct sockaddr_storage);
        	connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
        	add_client(connfd, &pool);
        }
        // 그리고 연결 돌때마다 client도 처리해주구
        check_clients(&pool);
        
     }
 }

init_pool 함수

는 클라이언트 풀을 초기화한다.
clienfd는 연결된 식별자들 집합으로,
정수 -1로 가용 공간을 나타내고 있다.

연결된 식별자는 전부 비워두고,
듣기 식별자만 살려놨다.

void init_pool(int listenfd, pool *p)
{
	int i;
    p->maxi = -1;
    for (i=0; i<FD_SETSIZE; i++)
    	p->clientfd[i] = -1;
        
    p->maxfd = listenfd;
    FD_ZERO(&p->read_set);
    FD_SET(listenfd, &p->read_set);
    
 }

add_client 함수

새로운 클라이언트를
클라이언트 풀에 추가한다.

pool을 돌면서
1)가용 공간에만 채우고
2)채웠으니 집합에 등록하고
3)현재 풀 상황 갱신
이외 꽉 찼으면 예외처리.

void add_client(int connfd, pool *p)
{
	int i;
    // 준비한거 넣을거니까 하나뺌
    p->nready--;
    
    //꽉 차지만 않았다면 순회해서 넣어보겠음
    for (i = 0; i <FD_SETSIZE; i++)
    	// 가용이면 채우겠다
    	if(p->clientfd[i] < 0){
        p->clientfd[i] = connfd;
        Rio_readinitb(&p->clientrio[i], connfd);
        
        //열린 프로세스니까 집합에 등록해놔야징
        FD_SET(connfd, &p->read_set);
        
        //현재 최대 fd로 등록해놓구
        if (connfd > p->maxfd)
        	p->maxfd = connfd;
        //pool에서도 최대 i면 갱신해야징
        if (i> p->maxi)
        	p->maxi = i;
        break;
        }
        
   //꽉 차버렸으면 예외처리.     
   if (i == FD_SETSIZE)
   	app_error("add_client error: Too many clients");
}

check_clients 함수

클라이언트를 찾는대로 echo 시킨다

1) 전부 수색 집합에 넣음
2) 그중 세팅된 놈 하나씩 읽음
2-2) 안 읽히면 닫음

흠 근데 수신한 전체 바이트 수 누적은
왜 저장하지...

void check_clients(pool *p)
{
	int i, connfd, n;
    char buf[MAXLINE];
    rio_t rio;
    
    
    // 끝에 도달하기 전까지 준비된 놈 하나라도 있으면 일단 전부 수색하겠음
    for (i = 0; (i <= p->maxi) && (p->nready > 0); i++){
    	connfd = p->clientfd[i];
    	rio = p->clientrio[i];
    
    //오 얘 존재도 하고 준비도 되어있어?
    if ((connfd>0) &&(FD_ISSET(connfd, &p->ready_set))){
    	p->nready--;
        //내용 가져와서 읽어야징
      	if((n= Rioreadlineb(&rio, buf, MAXLINE)) != 0) {
    		byte_cnt += n;
    		printf("Server received %d (%d total) bytes on fd %d\n", n, byte_cnt, connfd);
    		Rio_writen(connfd, buf, n);
    	}
    
    	else{
        //아 안 읽히는데? 닫겠습니다
    		Close(connfd);
    		FD_CLR(connfd, &p->read_set);
    		p->clientfd[i]= -1;
    
    		}
    	}
    	}    
}

12.2.2 I/O 다중화의 장단점

장점

  • 프로세스 기반 설계보다 프로그램을 더 잘 제어할수 있게 됨
    (일부 클라이언트에게 우선 서비스 제공 등이 가능)
  • 단일 프로세스 컨텍스트에서 돌아가므로 모든 논리 흐름은 프로세스의 전체 주소공간에 접근 가능.
    흐름들 간 데이터 공유 용이하게 함.
  • GDB 등에서 순차 프로그램에서 하는 것처럼 동시성 서버 디버그가 용이함.
  • 프로세스 기반 설계보다 훨씬 더 효율적.
    (새 일 한다고 문맥전환 하지 않으므로)

단점

  • 코딩 복잡도가 높음
  • 동시성 크기가 감소시 복잡성이 증가.
    (즉, 실행하는 인스트럭션이 적어도
    전환하게 하는 등을 하면 복잡해짐.)
  • 멀티 코어 프로세서를 완전히 활용할 수 없음.

12.3 쓰레드를 이용한 동시성 프로그래밍

쓰레드
프로세스 컨텍스트 내에서 돌아가는 논리흐름이다.

현대의 시스템은
다수의 쓰레드가 하나의 프로세스에서
동시에 돌아가는 프로그램을 작성한다.

쓰레드는 커널에 의해 자동으로 스케줄된다.

각 쓰레드는,
고유의 정수 쓰레드 ID, 스택,
스택 포인터, 프로그램 카운터, 범용 레지스터,
조건 코드를 포함하는

자신만의 Thread context를 가진다.

한 개의 프로세스에서 돌고 있는 모든 쓰레드는
이 프로세스의 전체 가상 주소를 공유한다.

프로세스와 같이
쓰레드는 거널에 의해 자동으로 스케줄되고,
커널에 정수 ID로 알려지는데,

다수의 쓰레드는
한개의 프로세스 컨텍스트 내에서 돌아간다.
여기에는 코드, 데이터, 힙, 공유 라이브러리, 오픈한 파일이 포함된다.

12.3.1 쓰레드 실행 모델

다중 쓰레드에 대한 실행 모델은
다중 프로세스를 위한 실행 모델과
어떤 면에서는 비슷하다.

어떤 시점에서
main은 peer를 생성하고
이때부터 두 스레드가 동시에 돌아간다.

제어가 피어로 옮겨지는 과정은
1)메인이 read나 sleep같은 느린 시스템 콜을 실행했거나
2)시스템의 인터벌 타이머에 의해 중단 되었기때문이다.

피어는 메인에게 돌려주기전
잠시 실행하는 등으로 진행된다.

단, 쓰레드의 실행은 일부 중요한 부분에서
프로세스와 다르다.

  • 쓰레드 컨텍스트가 더 작기때문에
    쓰레드 문맥전환은 프로세스보다 빠르다.
  • 쓰레드는 부모-자식 계층 구조가 아니라
    피어들의 풀을 구성하고
    다른 쓰레드에 의해 생길수 있으며
    메인 쓰레드의 의미는 항상 프로세스에서 돌아가는
    첫번째 쓰레드라는 의미에서만 구별됨.

피어의 풀에 관한 중요 개념은

  • 쓰레드가 자신의 피어 모두 죽일 수 있음
  • 자신의 피어들이 종료하는 것을 기다릴 수 있음.
  • 각 피어는 동일한 고유데이터를 읽고 쓸 수 있음.

12.3.2 Posix 쓰레드

Posix쓰레드는
C 프로그램에서 쓰레드를 조작하는 표준 인터페이스다.

시스템상태 변화를 피어들에게 알리기 위해
프로그램이 쓰레드 생성, 죽이기, 청소 등의 60개 함수를 정의.

아래는 간단한 프로그램이다.

메인 쓰레드는 피어쓰레드를 생성하고,(Pthread_create)
이것이 종료하기를 기다린다.(Pthread_join)

메인 쓰레드가 피어쓰레드 종료를 검출하면
이 프로세스를 exit 호출해 종료한다.

#include "csapp.h"
void *thread(void *vargp);

int main()
{
	//피어 쓰레드 생성 및 기다림
	pthread_t tid;
    Pthread_create(*tid, NULL, thread, NULL);
    Pthread_join(tid, NULL);
    exit(0);
}

// 피어쓰레드가 하는 일
void *thread(void *vargp)
{
	printf("Hello, world!\n");
    return NULL;
}

만일 다수의 진자를 쓰레드 루틴으로 전달하려고하면,
이 인자들을 구조체에 넣고
그 구조체 포인터를 전달한다.

반대로 쓰레드 루틴이 다수의 인자를 리턴하기 원한다면
구조체의 포인터를 리턴할 수 있다.

12.3.3 쓰레드 생성

pthread_create함수로
다른 쓰레드를 생성.

새 쓰레드를 만들고
쓰레드 루틴 arg를
새 쓰레드 컨텍스트 내에서 입력인자 *attr를 가지고
실행된다.

예제에서는 항상 NULL attr인자로 한다.
리턴한 값은 새로 만들어진 쓰레드 ID를 갖는다.

#include <pthread.h>
typedef void *(func)(void *);

int pthread_create(pthread_t *tid, 
		pthread_attr_t *attr, func *f, void *arg);
        
        // 0리턴시 성공, 0이 아니면 에러.

새 쓰레드는 pthread_self 함수를 호출해
자신의 쓰레드 ID를 불러올 수 있다.

#include <pthread.h>

pthread_t pthread_self(void);
// 호출한 얘의 thread ID를 가져옴.

12.3.4 쓰레드 종료하기

쓰레드는 다음 중 하나로 종료한다.

  • 자신의 최상위 쓰레드 루틴이 리턴할때 묵시적으로 종료.
  • pthread_exit 함수를 호출해 명시적으로 종료.
    메인 쓰레드가 pthread_exit 호출시 모든 쓰레드가 종료하길 기다리고,
    메인 쓰레드와 전체 프로세스를 thread_return 리턴 값으로 종료.
void pthread_exit(void *thread_return);

(정확히는, 리턴 시 특정 값을 전해서
종료 상태를 명시한다.)


void *thread_function(void *arg) {
    printf("Thread is running.\n");
    
    // 스레드 종료, 종료 상태로 42를 전달
    pthread_exit((void *)42);
}

(이런 식으로 메인은 메인대로 _join을,
피어 쓰레드는 exit를 호출하는 것이다.)

  • 일부 피어 쓰레드는 리눅스 함수를 호출하여 이 프로세스에 관련된 쓰레드를 모두 종료.
  • pthread_cancel 함수는 현재 쓰레드 ID로 호출해서 현재 쓰레드를 종료한다.
#include <pthread.h>

int pthread_cancel(pthread_t tid);

호출하면 종료 요청 중이지만,
취소 가능 상태가 되기 전까지 실행되진 않음.

(저런식으로 result로 캔슬 여부 확인함.)

  // 스레드 취소 요청
    pthread_cancel(tid);

    // 생성한 스레드의 종료를 기다림
    void *result;
    pthread_join(tid, &result);

    if (result == PTHREAD_CANCELED)

12.3.5 종료한 쓰레드의 삭제 Reaping

#include <pthread.h>

int pthread_join(pthread_t tid, void **thread_return);

join 함수를 호출하여 다른 쓰레드가 종료되길 기다린다.

join 함수는 쓰레드 tid가 종료될때까지 멈춰있으며,
쓰레드 루틴이 리턴하는 thread_return이 가리키는 위치에 넣어서
그 후 종료된 쓰레드가 가지고 있던 모든 메모리 자원을 삭제한다.

리눅스 wait와 달리
특정 쓰레드가 종료하길 기다린다.
임의의 쓰레드 종료를 기다리도록 할 수는 없다.
(그래서 누군 이거 버그라고 했다고함)

12.3.6 쓰레드 분리하기

#include <pthread.h>

int pthread_detach(pthread_t tid);

쓰레드는 joinable, 연결 가능 하거나 분리되어 detached 있다.

연결 가능 쓰레드는
다른 쓰레드에 의해 청소되고 종료될 수 있다.

대신 다른 쓰레드가 청소할때까진 메모리를 차지한다.

분리된 쓰레드는
다른 쓰레드에 의해 청소되거나 종료되진 않지만
종료되면 시스템에 의해 자동 반환된다.

기본적으로 쓰레드는 연결 가능으로 설정되지만
메모리 누수 방지를 위해
다른 쓰레드에 의해 명시적 소거되거나,
그냥 분리시켜 버린다.

(그래서 얘는 걔 피어 쓰레드 루틴 내에서 실행한다.)
pthread_detach(pthread_self()) 처럼 쓰면 좋겠지?)

? 겸사겸사 본 고아 프로세스와 좀비 프로세스 차이

고아 프로세스는 걘 실행중이다
좀비 프로세스는 종료되었는데 종료되었다고 알려지지않아서
메모리를 차지하고 있는 놈이다

12.3.7 쓰레드 초기화

#include <pthread.h>

int pthread_once(pthread_once_t 
		*once_control, void (*init_routine)(void));

초기화 코드가 여러 스레드에서 동시에 호출되더라도
단 한번만 실행되도록 보장한다.

once_control :(한번만 실행하기 위한 제어변수. 처음엔 PTHREAD_ONCE_INIT로 초기화되어있다가...)
init_routine : 한번만 실행되어야하는 코드블록. 함수 포인터를 받는다. 이 코드는 pthread_once를 호출한 스레드 중 하나에서만 실행된다.

// 한 번만 실행되어야 하는 초기화 코드
void initialize_once(void) {
    printf("Initialization code executed.\n");
}

// 스레드 함수
void *thread_function(void *arg) {
    // 초기화 코드를 스레드 안전하게 실행
    static pthread_once_t once = PTHREAD_ONCE_INIT;
    pthread_once(&once, initialize_once);

이 함수는 아무것도 리턴하지않고,
인자도 없다.

다수의 쓰레드에 의해 공유된 전역변수들을 초기화할필요가 있을때 유용하다.

12.3.8 쓰레드에 기초한 동시성 서버

#include "csapp.h"

void echo(int connfd);
void *thread(void *vargp);

int main(int argc, char **argv)
{
	int listenfd, connfd;
    socklen_t clientlen; 
    struct sockaddr_storage clientaddr;
    pthread_t tid;
    
    if (argc != 2) {
    	fprintf(stderr, "usage: %s <port> \n", argv[0]);
        exit(0);
    }
    
    listenfd = Open_listenfd(argv[1]);
    
    
    //포인터로 받아와서 쓰레드로 넘겨버려야겠다...
    while(1) {
    	clientlen=sizeof(struct sockaddr_storage);
        connfdp = Malloc(sizeof(int));
        *connfdp = Accept(listenfd, (SA *) *clientaddr, &clientlen);
        Pthread_create(&tid, NULL, thread, connfdp);
        } 
}

//쓰레드 루틴
void *thread(void *vargp)
{
	//메모리 주소를 int로 받아서 읽어서 값 가져와야지...
	int connfd = *((int *)vargp);
    
    // 분리 시켜놓고...
    Pthread_detach(pthread_self());
    Free(vargp);
    
    // echo 돌려야지
    echo(connfd);
    Close(connfd);
    return NULL;
}

살펴봐야할 이슈가 몇개 있다.

  • pthread_create 호출 시 인자 전달 방법.
    (그래서 포인터로 보내고, 피어가 역참조 하게 해버렸다.)

그렇지만 이렇게 하면
피어 할당문(int connfd...)과 accept문 사이 경쟁상태(race)를 생기게할 수 있어서

만약 accept가 더 빨라버리면
이번 연결 식별자가 아니라 다음 연결 식별자를 받아버린다!

그래서 이를 피하기위해
accept에 리턴되는각각 연결 식별자를
자신만의 동적으로 할당된 메모리 블록에 할당해야한다...

또 다른 이슈는
쓰레드 루틴에서 메모리누수를 피해야하기때문에,
detach로 분리시키고, 할당했던 메모리블록도 반환한다(Free)

12.4 쓰레드 프로그램에서 공유 변수

다수의 쓰레드는
같은 프로그램 변수 공유하기가 쉽지만
공유는 까다로울 수 있다.

공유한다는것이 무슨 의미인가?
어떻게 동작하는가?

....

어떤 변수가 공유되었는지 아닌지 이해하기 위해
몇 가지 기본적 질문에 답해보자.

  • 쓰레드를 위한 하부 메모리 모델은 무엇인가?
  • 이 모델이 주어지면 변수들은 어떻게 메모리에 매핑되는가?
  • 마지막으로, 얼마나 많은 쓰레드들이 이 변수들을 참조하는가?

변수는
다수의 쓰레드가 이 변수의 일부 인스턴스instance
참조할때에만 공유된다.

? 객체? 인스턴스?

객체는 특정한 목적을 가지고 데이터와 해당 데이터를 처리하는 메서드(함수)를 함께 묶어 놓은 소프트웨어의 기본 단위.

객체는 특정 클래스(Class)의 인스턴스(Instance)로서, 클래스는 객체를 생성하기 위한 일종의 설계도이며, 객체는 해당 클래스의 설계도에 따라 실체화된 것입니다. 객체는 데이터(속성, 멤버 변수)와 행동(메서드, 멤버 함수)을 함께 가지고 있습니다.

"객체"라는 용어의 어원은 라틴어 "objectus"에서 파생되었습니다. "objectus"는 "던지다", "제시하다"라는 동사 "objicere"에서 나왔습니다. 이 동사는 "ob-"(밖으로) + "jacere"(던지다)로 구성.

인스턴스는 클래스의 정의에 따라 생성된 구체적인 데이터로, 해당 클래스의 멤버 변수와 메서드를 포함.

"인스턴스"는 라틴어 "instantia"에서 파생되었으며, "실체"나 "사례"를 의미합니다. 객체 지향 프로그래밍에서 클래스는 추상적인 틀을 제공하며, 이를 기반으로 실제 데이터를 다루기 위해 인스턴스를 생성.

...

#include "csapp.h"
#define N 2
void *thread(void *vargp);

char **ptr;

int main()
{
	int i;
    pthread_t tid;
    char *msgs[N] = {
    	"Hello from foo",
        "Hello from bar"
        };
        
    ptr = msgs;
    for (i = 0 ; i < N ; i++)
    	Pthread_create(&tid, NULL, thread, (void *)i);
    Pthread_exit(NULL);
}

void *thread(void *vargp)
{
	int myid = (int)vargp;
    static int cnt = 0;
    printf("[%d] : %s (cnt=%d)\n", myid, ptr[myid],++cnt);
    return NULL;
}

공유에 관한 측면을 보는 예제 코드.
두 개의 피어쓰레드를 생성하고 있고,
한 개는 메인 쓰레드로 이루어짐.

메인 쓰레드는 각 피어 쓰레드에 고유의 ID를 전달하고,
이들은 ID를 사용하여 이 쓰레드 루틴이 호출된 횟수에 따라
구별된 메시지를 인쇄한다.

12.4.1 쓰레드 메모리 모델

쓰레드는 자신만의 쓰레드 컨텍스트를가진다.
자신만의 컨텐스트 외는
다른 쓰레드와 프로세스 컨텍스트를 공유한다.

하나의 쓰레드가 다른 쓰레드의 레지스터를 읽거나 쓰는건 불가능하지만
모든 쓰레드는 공유 가상 메모리 내 모든 위치에 접근할 수 있다.

어떤 쓰레드가 한 메모리 위치를 수정하면
그 위치를 읽는 다른 모든 쓰레드는 이 변경사항을 알 수 있다.

별도의 쓰레드 스택 메모리 모델은 깔끔하지 않다.
스택에서 독립적으로 접근되긴 하지만 특별히 보호되는건 아니라서
다른 쓰레드의 스택을 가리키는 포인터를 획득하면 이 스택을 읽고 쓸수 있다(...)

그래서 예제 코드에서 전역 ptr변수는 메인 쓰레드 내용을 참조하고 있지않은가!

12.4.2 변수들을 메모리로 매핑하기

쓰레드를 사용하는 C프로그램 변수들은
이들의 저장 클래스에 따라
가상메모리에 매핑된다....

  • 전역변수 (Global variables)
    가상 메모리 읽기/쓰기 영역에 한 개의 런타임 인스턴스를 가짐.
    그러면 변수 이름만 있으면 표시할 수 있겠지.

  • 지역 자동 변수 (Local automatic variables)
    함수 내에서 static 특성 없이 선언.
    런타임에 각 쓰레드 스택이 자신만의 지역 자동 변수 인스턴스를 가짐.
    다수의 쓰레드가 동일한 쓰레드 루틴을 쓰더라도 그렇다.
    (쓰레드마다 같은 함수를 참조해도 쓰레드별 지역 변수가 생긴다는 말인듯.)

  • 지역 정적 변수 (Local static variables)
    함수 안에서 static 특성으로 선언된 변수.
    가상 메모리 읽기/쓰기 영역에서 딱 한 개의 인스턴스만 생김.
    동일 쓰레드 루틴이어도 모두 같은 값을 참조함.
    (실행되는 쓰레드가 달라도 변수를 공유함.)

    12.4.3 공유 변수(Shared Variables)

    어떤 변수 v는
    자신의 인스턴스 중 하나가 하나 이상의 쓰레드에 의해 참조되는 경우만
    공유되어있다고 한다.

    static으로 선언하면 보통 당연히 공유되지면,
    지역 자동 변수도 공유될 수 있다.

    12.5 세마포어로 쓰레드 동기화하기

    공유 변수들 편리하지만 심각한 synchronization errors, 동기화 오류를 가져올 수 있다.

    ....
    공유 변수를 쓸 때
    어셈블리 수준에서
    인스트럭션 순서가 교차하면서
    생기는 오류에 관해 설명하고 있다.

    그리고 그 진행 순서를 진행 그래프, Progress Graphs라는
    개념으로 확인할 수 있다고 얘기하고 있다.

    12.5.1 진행 그래프

    두개의 쓰레드가
    인스트럭션을 교차해서 진행하는 걸
    보여주고 있다.

    이거 경로 찾기랑 생김새가 똑같잖아
    앞에서 공부한 개념을 써서 이걸 해결하다니
    (정확히는 그냥 현상을 수학적으로 풀이한 거겠지만)
    경이롭고 멋지다

    엄청 짱이니 당신도 책을 읽기 바란다
    행복해졌어...

    상호 배제 mutual exclusion

12.5.2 세마포어

"semaphore"이며, 이 용어의 어원은 그리스어에서 비롯되었습니다. "σῆμα"는 "표지" 또는 "신호"를 의미하고, "φορός"는 "보내는" 또는 "운반하는" 것을 나타냅니다. 따라서 "semaphore"는 글자나 기호를 사용하여 정보를 전달하거나 표시하는 통신 시스템을 나타내는 기술적인 용어로 사용됩니다.

지피티가 그냥 입턴거 아닐까? 의심된다.

철도의 까치발 신호기 또는 해군의 수기 신호라는 뜻으로, 복수의 작업을 동시에 병행하여 수행하는 운영 체제(또는 프로그래밍)에서 공유 자원에 대한 접속을 제어하기 위하여 사용되는 신호.

뭐 단어 출처는 이거긴한데 아예 어원은 저게 맞는거같긴하다.

아무튼.

세마포어 s는 비음수 정수 값을 갖는 전역 변수로,
두개의 특별한 연산인 P, V를 통해서만 조작할 수 있다.

(P는 Proberen, V는 Vehogen으로
테스트하기, 증가시키기라고 한다. 네덜란드어인가?)

  • P(s): s가 0이 아니면 P는 s를 감소시키고 즉시 리턴한다.
    만약 s가 0이면 이 쓰레드는 s가 0이 아닌 값을 가지고,
    쓰레드가 V연산에 의해 재시작될때까지 정지된다.
    재시작 후에 P연산은 s를 감소시키고 제어를 호출자에게 돌려준다.
  • V(s): V연산은 s를 1 증가시킨다.
    만일 s가 0이 아닌 값을 가지고 P연산에 멈춰있는 쓰레드가 있다면,
    V연산은 이 중에서 정확히 한 개의 쓰레드를 재시작하고,
    그 후에 s를 감소시키면서 자신의 P연산을 완료한다.

P에서
일단 세마포어 s가 0이 아니면 s의 감소가 중단없이 일어난다.
V에서 증가연산도 세마포어를 중단없이 로드하고, 증가하고, 저장한다.

V를 기다리는 쓰레드들의 재시작 순서를 정의하진 않는다는 걸 주목하라.
유일한 요구사항은, V는 정확히 한 개의 쓰레드만 재시작한다는 것이다.

그래서 여러개의 쓰레드가 하나의 세마포어를 기다리고 있다면,
어떤 것이 V의 결과로 재시작되는지 예측할 수 없다.

P와 V는 적절히 초기화된 세마포어가 음수값을 가지는 상태로
절대 들어갈 수 없도록 보장하여, semaphore invariant, 불변성이라고 한다.

이러한 특징이 동시성 프로그램 궤적 제어에 도움을 준다.

Posix 표준은
세마포어를 다루기 위한 여러가지 함수를 정의한다.

#include <semaphore.h>

int sem_init(sem_t *sem, 0, unsigned int value);
int sem_wait(sem_t *s);
int sem_post(sem_t *s);

sem_init 함수는 value 만큼 초기화한다.
각각 세마포어는 사용하기전 반드시 초기화해야한다.
(중간 인자는 편의상 항상 0으로 하였음)

sem_wait, sem_post에 대한 편의상
P, V로 한다.

#include "csapp.h"

void P(sem_t *s);
void V(sem_t *s);

12.5.3 상호 배제를 위해 세마포어 이용하기

...

하나의 세마포어 s를 초기값 1로 시작해서
각각의 공유 변수(또는 공유 변수 관련 집합)에 연계해서
그 후에 대응하는 크리티컬 섹션을 P(s)와 V(s) 연산으로
둘러싸는 것이다...

이럭식으로 공유 변수를 보호하기 위해
이용하는 세마포어를 binary semaphore 라고 부르는데,
그 이유는 이들의 값이 항상 0 또는 1이기 때문이다.

상호 배타성을 제공하는 목적의
바이너리 세마포어를 뮤텍스, Mutex라고도 한다.
(Mutual exclusion의 줄임말이라고 하는군...)

뮤텍스에서 P연산을 수행하는 걸
locking, 뮤텍스를 잠근다라고 하고,
V연산을 수행하는 걸
뮤텍스를 연다, unlocking이라고 한다.

뮤텍스를 잠갔으나 아직 열지 못한 쓰레드는
뮤텍스를 소유하고 있다, holding, 라고 한다.

가능한 자원들의 집합에 대한 카운터로 이용된 세마포어는
counting semaphore라고 한다.

그래서..
진행 그래프로..
바이너리 세마포어를 이용해
예제 카운터 프로그램이
어떻게 동기화하는지 보여준다...

핵심 아이디어는
이와 같은 P와 V의 조합이 금지된 구역, forbidden region이라고
부르는 상태들의 집합을 생성한다는 것이다...
이때, s<0이 된다.

세마포어의 불연성으로 인해 금지된 구역상태를
하나라도 포함하는 가능한 궤적이 존재하지 않는다.
그런데 금지 구역이 위험 영역을 감싸고 있어
위험 영역을 지날수 없다...

그래서 모든 가능한 궤적이 안전해져서
런타임에 인스트럭션 순서와 무관하게
이 프로그램은 정확하게 카운터를 증가시킨다.

(코드없나...)

volatile int cnt = 0;
sem_t mutex

Sem_init(&mutex, 0, 1);

for (i=0; i<niters; i++){
	P(&mutex);
    cnt++;
    V(&mutex);
}

음.
한쪽이 P를 진행하면,
다른 한쪽도 p를 진행할 수가 없음.
(처음값이 1인데, 두번이나 1을 빼면 -1이 되어버리니까.)
그래서 다른쪽이 V를 할때까지 그쪽은 기다리고 있어야하기때문에
일종의 wait 같은 역할을 한다.

12.5.4 세마포어를 이용한 공유 자원 스케줄링하기

세마포어의 또다른 중요 용도는
상호 배제 제공 외에도 공유 자원으로의 접근을 스케줄링 하는 것이다.

쓰레드는 세마포어 연산을 이용해
프로그램 상태의 어떤 조건이 참이 되었다는 것을
다른 쓰레드에 알려준다.

두개의 고전적이고 유용한 사례는 생산자- 소바자와 reader-writer 문제.

생산자 - 소비자(Producer-consumer) 문제

(생산자 스레드 ) -> Bonded buffer -> consumer thread

생산자는 아이템들을 생성하고
이들을 제한된 버퍼에 추가한다.

소비자는 이들을 버퍼에서 제거하고
그 후에 이들을 소비한다.

....

생산자와 소비자 쓰레드는 n개의 슬롯을 갖는 제한된 버퍼를 공유한다.

reader-writer 문제

12.5.5

profile
개발자 희망...

0개의 댓글