리눅스에서는 현재 사용자가 누구인지를 나타내는 방법이 바로 사용자 아이디이다. 하지만 프로그래밍을 하다보면 자신이 아닌 다른 사람이 만든 파일이나 프로그램을 사용하고 싶거나 내용을 바꿔야 할 때가 있다. 이런 상황을 위해 리눅스에서는 세 가지 ID를 사용한다.

 

사용자 아이디 종류

 

1. 실제 사용자 ID(Real User ID) : 프로그램을 실제 수행시키는 사용자에게 속한 유효 사용자 ID

  • 프로세스를 실행한 사용자의 ID이다.
  • 실제 사용자 ID는 부모 프로세스의 실제 사용자 ID로 설정된다.
  • exec() 호출 도중에 변경되지 않는다.
  • 루트 권한을 갖는 프로세스만 실제 사용자 ID를 다른 값으로 변경할 수 있다.
  • 루트 권한을 제외하고는 생성하는 자식 프로세스들은 같은 RUID를 가진다.

2. 유효 사용자 ID(Effective User ID) : 프로세스 실행 자격을 확인하는 사용자 ID

  • 프로세스가 영향을 미치는 사용자 ID이며 접근 권한을 판단할 때 사용한다.
  • 프로세스를 처음 생성할 때는 부모 프로세스의 유효 사용자 ID를 상속받는다. 따라서 EUID와 RUID가 같다.
  • exec()함수를 호출할 때에도 일반적으로 RUID는 변경되지 않는다.
  • 프로세스는 유효 사용자 ID를 변경 가능하다. -> setuid bit가 설정되어 있는 프로그램을 실행할 때만 가능하다.

3. 저장된 사용자 ID(Saved User ID) : exec()호출 시 변경되기 전 유효 사용자 ID

  • 프로세스의 원래 유효 사용자 ID
  • 프로세스가 fork()를 실행할 때 자식 프로세스는 부모 프로세스의 저장된 사용자 ID를 상속받는다. 즉 부모 프로세스의 바뀐 유효 사용자 ID가 아니라 원래 유효 사용자 ID이다.
  • exec()도 동일하게 커널은 저장된 사용자 ID를 유효 사용자 ID로 설정한다.
  • 프로그램의 사용자 ID가 유효 사용자 ID에 복사될 때 이전 유효 사용자 ID는 저장된 사용자 ID에 복사된다. 그림으로 나타내면 아래와 같다.
  • 루트 권한을 갖는 프로세스만 실제 사용자 ID와 동일한 값으로 변경 가능하다.

setuid bit가 설정된 프로그램을 실행할 경우의 RUID, EUID, SUID의 변경 과정

실제 예시

/usr/bin/passwd 파일은 패스워드를 변경하는 프로그램이고 소유자는 root이다.

패스워드가 들어있는 파일은 다른 파일에 존재하고 소유자는 root이다. 그리고 소유자만 이 파일을 읽고 쓸 수 있다.

/usr/bin/passwd 파일은 setuid설정된 파일이다.

소유자는 root이므로 일반 사용자가 이 프로세스(/usr/bin/passwd)를 실행할 경우 일반 사용자의 EUID는 root가 된다. 따라서 일반 사용자도 EUID가 root이므로 패스워드가 들어있는 파일의 접근 권한과 같으므로 패스워드가 들어있는 파일에 읽고 쓸 수 있다.

즉 일반 사용자는 패스워드가 들어있는 파일에 직접 읽고 쓸 수 있는 것이 아니라 /usr/bin/passwd라는 패스워드를 변경하는 프로그램을 이용해 패스워드가 들어있는 파일에 읽고 쓸 수 있는 권한을 얻어 변경할 수 있는 것이다.

Exec함수란?

    이전 글(https://shyeon.tistory.com/51)에서 fork, vfork를 이용하여 자식 프로세스를 생성하여 다중 프로세스를 이용하는 프로그램을 만드는 법을 알아보았다. vfork를 이용할 경우 부모 프로세스는 자식 프로세스를 기다리는 것을 보장하고 주로 exec 계열 함수를 호출할 경우 사용한다고 했다. 그렇다면 exec 계열 함수는 어떤 기능을 수행할까?

 

    exec 계열 함수는 현재 실행되고 있는 프로세스를 다른 프로세스로 대신하여 새로운 프로세스를 실행하는 함수이다. 즉 다른 프로세스로 대체되어 주어진 경로에 있는 새로운 프로세스의 main함수부터 다시 시작된다. 

 

함수

int execl(const char *pathname, const char *arg0, ... /* (char *)0*/);
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, .../*(char *)0, char *const envp[] */);
int execve(const char *pathname,  char *const argv[], char *const envp[]);
int execlp(const char *filename, const c har *arg0, .../* (char *)0 */);
int execvp(const char *filename, char *const argv[]);

6개의 함수들이 전부 비슷하게 생겼다. 하지만 규칙을 찾을 수 있다.

  1. 뒤에 l 이 붙은 경우 : 매개변수의 인자가 list 형태이다. 즉 char*형을 나열한다.
  2. 뒤에 v가 붙은 경우 : 매개변수의 인자가 vector 형태이다. 두 번째 인잘르 보면 2차원 배열로 char*형을 배열로 한 번에 넘긴다.
  3. 뒤에 e가 붙은 경우 : 환경변수를 포함하여 넘긴다. 
  4. 뒤에 p가 붙은 경우 : 경로 정보가 없는 실행 파일 이름이다. 만약 filename에 ' / '(슬래시)가 포함되어 있으면 filename을 경로로 취급하고 슬래시가 없으면 path로 취급한다.

list 형태와 vector 형태로 넘기는 매개변수의 맨 마지막은 NULL pointer가 존재해야한다.

위 함수중 execve() 함수만 커널에서 제공하는 system call 함수이고 나머지는 라이브러리 함수이다.

 

exec 계열 함수를 호출한 프로세스에서 대체되는 프로세스로 상속되는 특징

  • 프로세스 ID와 부모 프로세스 ID
  • 실제 사용자 ID와 실제 그룹 ID
  • 추가 그룹 ID
  • 프로세스 그룹 ID
  • 세션 ID
  • 제어 터미널
  • alarm 발동까지 남은 시간
  • 현재 작업 디렉토리
  • 파일 생성 마스크
  • 파일 자물쇠들
  • 프로세스 시그널 마스크
  • 아직 처리되지 않은 시그널
  • 자원 한계
  • tms_utime, tms_stime, tms_cutime, tms_cstime

 

예제

이번 예제는 두개의 프로그램을 짜서 하나의 프로그램에서 exec함수를 실행하여 다른 프로그램으로 대체하는 프로그램을 짜보겠다.

 

1. exec1.c(exec을 호출하여 exec2를 실행하는 프로그램)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
	pid_t pid;

	if((pid = fork()) == 0) { //자식이 실행
		char *args[] = {"hello", "my", "name", "is", "shyeon", NULL}; //exec호출시 넘길 인자들
		if(execv("./exec2", args) < 0) { //exec2를 실행한다.
			fprintf(stderr, "execv error\n");
			exit(1);
		}
	}
	if(wait(NULL) == pid) { //자식이 종료되길 기다림
		printf("\nchild process exited\n");
	}
	else { //에러시 에러문구 출력
		fprintf(stderr, "wait error\n");
		exit(1);
	}

	exit(0);
}

 

2. exec2.c(exec1으로부터 실행되는 프로그램)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char **argv) {

	printf("parent says : ");

	for(int i = 1; i < argc; i++) { //부모에서 넘긴 인자를 출력
		printf("%s ", argv[i]);
	}

	exit(0);
}

이렇게 exec1에서 exec2로 대체한 것처럼 하나의 프로그램에서 다른 프로그램을 호출하며 프로그램을 대체할 수 있다.

 

실행 결과

위 프로그램에 대한 실행 결과이다.

exec1을 실행해서 execv를 통해 exec2가 실행되는 것을 알 수 있다.

프로세스란?

  • 컴퓨터에서 연속적으로 실행되고 있는 컴퓨터 프로그램을 말한다.
  • 우리가 코드를 짜서 만든 프로그램을 컴파일하여 실행시키면 실행되고 있는 프로그램은 하나의 프로세스가 된다.
  • 우리가 일반적으로 짜는 프로그램은 단일 프로세스의 단일 스레드 프로그램이다.
  • 만약 한 프로세스에서 다른 프로세스를 생성할 경우 생성된 프로세스를 자식 프로세스라 부르고 생성한 프로세스를 부모 프로세스라 부른다.

 

부모 프로세스로부터 상속받는 속성

  • SUID 플래그와 SGID 플래그
  • 현재 작업하고 있는 디렉토리
  • 루트 디렉토리
  • 파일 생성 마스크
  • 시그널 마스크와 시그널 처리 설정
  • 열린 file discriptor에 대하여 exec 호출 시 닫기(close-on-exec) 플래그
  • 환경변수
  • 부착된 공유 메모리 영역
  • 메모리 매핑
  • 자원 한계

 

부모 프로세르로부터 상속받지 않는 속성

  • fork의 리턴값(부모는 0이 아닌 값, 자식은 0)
  • 프로세스 ID
  • 부모 프로세스 ID(생성된 프로세스는 자신을 생성한 프로세스의 ID가 부모 프로세스 ID로 설정된다.
  • 자식 프로세스의 tims_utime(user코드를 실행하는데 사용된 cpu 시간), tms_stime(커널 코드를 실행하는데 사용된 cpu 시간), tms_cutime(child user 시간), tms_cstime(child의 커널 시간)은 모두 0으로 설정
  • 부모가 잠근 파일 lock
  • 아직 발동되지 않은 alarm 시그널은 자식에서 모두 해제
  • 자식의 유보 중인 시그널 집합

 

함수

1. fork()

pid_t fork(void);
  • 새로운 자식 프로세스를 생성할 때 사용하는 시스템 호출 함수이다.
  • 한 번 호출에 두 개의 리턴 값을 리턴한다. 자식 프로세스에게는 , 부모에게는 자식 프로세스의 ID를 리턴한다.
  • 프로세스 생성이 실패한 경우 -1을 리턴한다.

2. vfork()

pid_t vfork(void);
  • fork()와 마찬가지로 자식 프로세스를 생성하는 함수이다.
  • fork와의 차이점은 자식 프로세스가 먼저 실행됨을 보장한다는 것 이다. 따라서 생성된 프로세스가 exec계열 함수를 이용하여 새 프로그램으로 실행하는 경우에 주로 사용한다.
  • vfork로 프로세스를 생성한 경우 exec계열 함수를 호출할 것으로 생각하므로 부모의 주소 영역을 참조하지 않을 것이라고 생각하여 부모 프로세스의 공간을 자식에게 복사하지 않는다.
  • 복사하는 시간이 소요되지 않으므로 fork보다 약간의 성능 향상이 있다.
  • 생성된 자식 프로세스는 exec계열 함수나 exit()을 호출할 때까지 부모 프로세스의 메모리 영역에서 실행되고 부모 프로세스는 자식 프로세스가 exec계열 함수나 exit()을 호출할 때까지 기다린다.
  • 자식 프로세스가 좀비 프로세스가 되는 것을 최대한 방지하고 자식 프로세스가 항상 먼저 실행되는 것을 보장한다.
  • 자식 프로세스를 종료할 때 _exit()을 이용하여야한다. 부모 프로세스의 표준 입출력 채널을 같이 사용하는데 자식 프로세스가 exit()을 호출할 경우 입출력 스트림을 모두 닫으므로 부모 프로세스에서는 입출력을 하지 못한다. 따라서 입출력 스트림을 정리하지 않고 종료시키는 _exit()을 사용해야 한다.

3. wait(), waitpid()

pid_wait(int *statloc);
pid_waitpid(pid_t pid, int *statloc, int options);
  • 프로세스의 종료 상태를 회수하는 system call 함수이다.
  • 자식 프로세스가 종료될 때까지 부모 프로세스가 기다리는 함수이다.
  • 자식 프로세스가 언제 종료하는지 부모 프로세스는 알 지 못하므로 커널에서 시그널(SIGCHLD)을 보내 부모 프로세스에게 종료 사실을 알린다.
  • 부모 프로세스는 시그널을 무시할 수도 있고 핸들러 함수를 등록하여 핸들러에게 규정된 동작을 수행하게 할 수 있다.
  • statloc이 가리키는 곳에는 자식 프로세스의 종료 상태를 저장한다. 만약 자식 프로세스가 존재하지 않을 경우 -1을 리턴한다.
  • 리턴 값은 종료된 프로세스의 pid값이다.
  • waitpid의 pid값에 따라 기다리는 자식 프로세스가 달라진다. 
    1. -1인 경우 임의의 자식 프로세스를 기다리며 wait()과 동일한 역할을 수행한다.
    2. 0보다 큰 경우 프로세스 id가 pid인 하나의 자식 프로세스를 기다린다.
    3. 0인 경우 프로세스 그룹 id가 호출한 프로세스와 동일한 임의의 자식 프로세스를 기다린다.
    4. -1보다 작은 경우 프로세스 그룹 id가 pid의 절대값과 같은 임의의 자식 프로세스를 기다린다.
  • wait()은 가장 먼저 종료된 자식 프로세스 하나만을 기다린다. 만약 자식 프로세스가 없는 경우 에러를 리턴한다. 종료된 프로세스가 없는 경우 부모 프로세스는 자식 프로세스를 기다린다.

 

예제

자식 프로세스를 생성하여 자식 프로세스에서 1부터 10까지 출력하고 부모는 자식프로세스를 기다린 다음 1부터 10까지 출력하는 프로그램이다.

 

소스 코드

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
	pid_t pid;
	
	if((pid = fork()) < 0) { //에러인 경우
		fprintf(stderr, "fork error\n");
		exit(1);
	}
	else if(pid == 0) { //이 괄호 안은 자식이 실행
		for(int i = 1; i <= 10; i++) {
			printf("child : %d\n", i);
		}
		exit(0);
	}
	else { //여기부터 부모가 실행
		if(waitpid(pid, NULL, 0) < 0) { //자식 프로세스가 끝날 때까지 기다림
			fprintf(stderr, "wait pid error\n");
			exit(1);
		}
		for(int i = 1; i <= 10; i++) { //부모 프로세스에서 1~10까지 출력
			printf("parent : %d\n", i);
		}
	}
	exit(0);
}

 

실행 결과

조건 변수를 사용하는 이유

    이전 글(https://shyeon.tistory.com/48)에서 mutex를 이용하여 스레드간 동기화를 하는 방법을 알아 보았다. 하지만 mutex만을 이용하여 동기화를 하는 것은 프로세스가 mutex변수를 지속적으로 검사해야 한다는 단점이 있다. 따라서 이 글에서 다루는 조건 변수(condition)을 함께 사용한다. 

 

함수

함수는 mutex함수와 굉장히 유사하다.

1. pthread_cond_init

int pthread_cond_init(pthread_cond_t *restrict condition, pthread_condattr_t *restrict attr);

mutex변수를 초기화하는 것처럼 cond변수도 초기화를 해야한다. 첫 번째 인자는 초기화하려는 cond 변수의 주소값을 넘기고 두 번째 인자는 cond 변수의 속성을 설정하는 변수이고 리눅스에선 무시하므로 NULL값을 주로 넣는다.

mutex 변수처럼 pthread_cond_init함수 말고 직점 변수에 PTHREAD_COND_INITIALIZER를 대입하여 초기화하는 방법도 있다.

 

2. pthread_cond_destroy

int pthread_cond_destroy(pthread_cond_t *condition);

mutex 변수와 마찬가지로 사용이 끝난 cond 변수는 pthread_cond_destroy함수를 호출하여 해제해줘야 한다.

 

3. pthread_cond_signal

int pthread_cond_signal(pthread_cond_t *cond);

cond 변수에 신호를 보내어 cond 변수를 기다리고 있는 스레드를 다시 시작시키는 함수이다. 만약 cond 변수를 기다리는 스레드가 없다면 아무 일도 일어나지 않느다. pthread_cond_signal함수는 cond 변수를 기다리는 여러 스레드 중에 단 하나만 다시 시작시킨다. 하지만 어떤 스레드를 시작시킬지는 지정할 수 없다.

 

4. pthread_cond_broadcast

int pthread_cond_broadcast(pthread_cond_t *cond);

하나의 스레드만 다시 시작시키는 pthread_cond_signal 함수와 다르게 cond 변수를 기다리고 있는 모든 스레드를 다시 시작시킨다.

 

5. pthread_cond_wait

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

cond 변수가 신호를 받을 때까지 기다리는 함수이다. 이 함수가 실행되면 mutex를 lock을 해제하고 기다린다. 따라서 다른 스레드에서는 mutex변수의 lock을 얻을 수 있다. 그 다음 이 함수를 벗어날 경우 mutex lock을 얻게 된다. 기다리는 시간을 지정할 수 있는 pthread_cond_timedwait도 있다.

 

예제

이 예제는 1부터 10까지의 숫자를 두 개의 스레드에서 출력한다. 1~3은 1번 스레드가, 4~7은 2번 스레드가, 8~10은 다시 1번 스레드가 실행한다. 1번 스레드가 실행할 부분은 2번 스레드에서 신호를 주어 cond변수를 이용하여 다시시작한다.

 

소스 코드

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //변수 초기화
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

int value = 0; //공유 변수

void *func1(void *arg) { //스레드1이 실행할 함수
	while(1) {
		pthread_mutex_lock(&mutex); //lock을 걸고
		pthread_cond_wait(&cond, &mutex); //기다림
		value++; //1 증가시키고
		printf("thread1 : %d\n", value); //출력

		pthread_mutex_unlock(&mutex); //lock 해제

		if(value >= 10) //10을 넘어갈 경우 종료
			return NULL;
	}
}

void *func2(void *arg) { //스레드2가 실행할 함수
	while(1) {
		pthread_mutex_lock(&mutex); //lock을 걸고

		if(value < 3 || value > 6) { //1번 스레드가 실행할 부분
			pthread_cond_signal(&cond); //cond변수를 기다리는 스레드 1번을 다시 시작시킴
		}
		else { //그 외의 부분은
			value++; //1 증가시키고
			printf("thread2 : %d\n", value); //출력
		}

		pthread_mutex_unlock(&mutex); //lock 해제

		if(value >= 10)
			return NULL;
	}
}

int main() {
	pthread_t tid1, tid2;

	pthread_create(&tid1, NULL, &func1, NULL); //스레드 1 생성
	pthread_create(&tid2, NULL, &func2, NULL); //스레드 2 생성

	pthread_join(tid1, NULL); //스레드1 리소스 반환
	pthread_join(tid2, NULL); //스레드2 리소스 반환

	pthread_mutex_destroy(&mutex); //mutex 변수 해제
	pthread_cond_destroy(&cond); //cond 변수 해제

	return 0;
}

 

실행 결과

1~3, 8~9는 1번 스레드가 출력하고 4~7은 2번 스레드가 출력하는 것을 볼 수 있다.

Mutex란?

  • Mutual exclusion의 줄임말이다. lock을 건다는 말과 같다.
  • 스레드간에 동기화와 여러 스레드에서 같은 메모리에 쓸 때 공유되는 데이터를 보호한다.
  • mutex 변수는 공유 데이터를 접근하는 것을 보호하기 위해 lock처럼 사용된다.
  • mutex변수를 공유하는 스레드 중 하나의 스레드만이 mutex 변수에 lock을 걸 수 있다.
  • 공유된 변수를 번갈아가며 사용할 때 사용된다 (race condition 방지)

Mutex의 사용 예시

위 그림과 같이 은행 데이터베이스에 shyeon은 1000원, gildong은 1500원이 있다고 하자. 이때 A에서 shyeon에게 500원을 송금하고자하고 동시에 B에서 700원을 송금하고자 한다. 이때 무슨 일이 일어날까? 각각 A, B에서는 이런 일이 발생할 것이다.

  • 은행 DB로부터 shyeon의 계좌 정보를 변수에 저장한다.
  • 그 변수에 500원을 더한다.
  • 은행 DB에 계산 결과값을 저장한다.

A, B에서 위의 일이 동시에 발생한다면 A, B 모두 은행 계좌로부터 shyeon의 돈은 1000원이 있다고 읽어오고 A는 1000원에 500원을 더한 1500원을 은행 DB에 보낼 것이고 B는 700원을 더한 1700원을 DB에 보낼 것이다. 이때 더 늦게 도착하는 값으로 DB에 있는 shyeon의 잔고가 갱신될 것이다. 이런 상황을 race condition이라고 말한다. 어떤게 먼저 도착하냐에 따라 값이 달라지는 상황이다. 이런 일을 방지하려면 어떻게 해야할까? A나 B가 순서를 정하여 DB에 접근하면 된다. 이런 순서를 정하기 위해 mutex가 사용된다.

 

mutex 관련 함수

1. pthread_mutex_init

int pthread_mutex_init(ptrhead_mutex_t* mutex, const pthread_mutexatt_t *attr);

    mutex변수를 초기화하는데 사용하는 변수이다. mutex변수를 선언하여 첫 번째 인자에 주소값을 넘겨 초기화 할 수 있다. attr인자는 NULL로 하면 가본 속성으로 생성할 수 있다. 리눅스에서는 기본값인 NULL값을 사용한다. mutex변수를 초기화하는 또다른 방법으로는 다음과 같이 선언하면 된다.

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

    위의 코드처럼 mutex변수에 직접 대입하는 방법이다. 이 방법은 attr인자를 사용할 수 없다는 단점이 있지만 리눅스에서는 고려하지 않는다.

 

2. pthread_mutex_destroy

int pthread_mutex_destroy(pthread_mutex_t *mutex);

    사용이 끝난 mutex 변수를 해제하는 함수이다. malloc으로 동적으로 선언한 mutex 변수는 free()를 호출하기 전에 꼭 pthread_mutex_destroy()를 먼저 호출해야한다.

 

3. pthread_mutex_lock

int pthread_mutex_lock(pthread_mutex_t *mutex);

    mutex 변수에 lock을 거는 함수이다. 두 스레드의 함수에서 같은 mutex변수에 대해 각각 pthread_mutex_lock을 호출할 경우 두 스레드 중 하나의 스레드만 mutex 변수에 대해 lock을 얻고 다른 스레드에서는 pthread_mutex_lock에서 기다리고 있다. 이때 mutex를 얻은 스레드에서 pthread_mutex_unlock()을 호출할 경우 다른 스레드에서 mutex변수에 lock을 걸 수 있다.

 

예제

아래 코드는 두 스레드에서 공유하는 하나의 변수에 번갈아가며 출력하는 예제이다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int value;

void *func1(void *arg) { //1번 스레드가 실행할 함수
	for(int  i = 0; i < 5; i++) {
		pthread_mutex_lock(&mutex); //mutex 변수 lock(다른 변수에서 접근 불가능)
		printf("thread1 : %d\n", value); //변수 출력
		value++; //변수 증가
		pthread_mutex_unlock(&mutex); //mutex 변수 unlock(다른 변수에서도 접근 가능)
		sleep(1);
	}

	pthread_exit(NULL); //스레드 함수 종료
}

void *func2(void *arg) { //2번 스레드가 실행할 함수
	for(int i = 0; i < 5; i++) { 
		pthread_mutex_lock(&mutex); //mutex 변수 lock(다른 변수에서 접근 불가능)
		printf("thread2 : %d\n", value); //변수 출력
		value++; //변수 증가
		pthread_mutex_unlock(&mutex); //mutex 변수 unlock(다른 변수에서 접근 가능)
		sleep(1);
	}

	pthread_exit(NULL); //스레드 함수 종료
}

int main() {
	pthread_t tid1, tid2; //스레드 변수 선언

	value = 0; //공유 변수 초기화

	if(pthread_create(&tid1, NULL, func1, NULL) != 0) { //1번 스레드 생성
		fprintf(stderr, "pthread create error\n");
		exit(1);
	}

	if(pthread_create(&tid2, NULL, func2, NULL) != 0) { //2번 스레드 생성
		fprintf(stderr, "pthread create error\n");
		exit(1);
	}

	if(pthread_join(tid1, NULL) != 0) { //1번 스레드 종료 후 리소스 회수
		fprintf(stderr, "pthread join error\n");
		exit(1);
	}

	if(pthread_join(tid2, NULL) != 0) { //2번 스레드 종료 후 리소스 회수
		fprintf(stderr, "pthread join error\n");
		exit(1);
	}

	pthread_mutex_destroy(&mutex); //mutex 변수 해제

	exit(0);
}

 

실행 결과

 

mutex lock 실행 결과
mutex lock 주석처리 후 실행 결과

    첫 번째 사진은 mutex lock을 사용한 결과이고 두 번째 사진은 pthread_mutex_lock(), pthread_mutex_unlock()부분을 주석처리하고 실행한 결과이다. 실행 결과 1번 사진과 같이 1번 스레드와 2번 스레드가 번갈아가며 변수를 증가하며 출력하는 것을 볼 수있다. 2번 사진은 공유된 변수에 동시에 접근하여 같이 출력되는 것을 알 수 있다. 이렇게 여러 스레드가 공유 변수에 접근하고자할 때 race condition을 방지하기 위해 mutex를 사용할 수 있다.

 

    pthread 라이브러리를 사용할 때는 컴파일할 때 -lpthread를 포함해줘야한다. 그렇지 않으면 에러가 발생하고 컴파일이 되지 않는다.

 

개발 환경

wsl2 Ubuntu-20.04

gcc (Ubuntu 9.3.0-17ubuntu1~20.04)

스레드란?

  • 프로세스 내의 제어 흐름
  • 일반적으로 우리가 작성하는 코드는 단일 스레드 단일 프로세스
  • 다중 스레드 프로세스는 하나의 프로세스에 여러 컨트롤이 존재함

쉽게 말해 스레드란 우리가 프로그램을 실행할 때 코드가 실행되는 흐름이라고 할 수 있다.

 

특징

  • 동일 프로세스에서 동작하는 여러 개의 스레드는 코드 영역, 데이터 영역, 리소스(파일 디스크립터, 시그널 등)를 공유한다.
  • 스레드는 각각 Program Counter(PC)를 가지고 있다 - 이는 생각해보면 당연하다. 각 스레드는 실행하는 코드의 위치가 다르다. 즉 실행하는 명령어가 다르다. 따라서 각각 PC를 가지고 있어야한다.
  • 스레드는 스택 영역, 레지스터 집합, 스레드 ID를  각자 가지고 있다.

 

예시

그러하면 실제로 다중 스레드는 어디에 이용될까? 우리가 사용하는 웹 브라우저에서도 다중 스레드는 사용된다. 예를 들어 이미지, 텍스트 등 화면에 홈페이지를 표시해주는 스레드가 1개 존재한다면 백엔드로부터 데이터를 읽어오는 역할을 수행하는 스레드가 따로 동작하고 있을 수 있다. 이렇게 동시에 여러 기능을 수행하려면 다중 스레드를 이용하여야 한다.

 

함수

리눅스에서 제공하느 pthread 시스템 호출 함수를 이용하여 다중 스레드를 사용할 수 있다. 그럼 스레드를 제어할 수 있는 함수는 어떤 것들이 있는지 간단히 살펴보겠다.

  • pthread_create()     
int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void *), void *restrict arg);

 - 새로운 스레드를 생성하는 함수이다. 함수의 원형은 위의 코드와 같다.

 - 첫 번째 인자로 새로 생성할 스레드의 ID가 저장될 주소 값을 넘겨준다.

 - 두 번째 인자는 기본 특성을 가지는 스레드의 경우 NULL을 넣어주고 pthread_attr_init()으로 pthread_attr_t 구조체를 초기화하여 사용자 모드 스레드나 커널 모드 스레드를 사용할 수 있다.

 - 세 번째 인자는 새로 생성되는 스레드가 실행할 함수의 포인터를 준다.

 - 네 번째 인자는 세 번째 인자의 함수에게 넘기고 싶은 정보를 구조체에 저졍하여 그 포인터를 넘겨주어 스레드가 실행하는 함수에서 그 정보를 사용할 수 있게 한다.

  • pthread_exit()
void pthread_exit(void *rval_ptr);

  - 스레드를 종료시키는 함수. 인자로 준 rval_ptr 값은 pthread_join의 두번 째 인자에서 받을 수 있다. 만약 pthread_detach()를 하지 않은 경우에는 스레드가 종료되었어도 스레드의 자원은 회수되지 않는다.

  • pthread_join()
int pthread_join(pthread_t thread, void **rval_ptr);

 - main스레드가 생성한 스레드가 종료될 때까지 기다리는 함수. 종료된 스레드의 자원을 회수하는 역할을 한다.

 - pthread_join()을 호출한 스레드는 그 스레드가 pthread_exit()을 호출할 때까지 대기한다.

 - main스레드의 종료로 인해 다른 스레드들이 강제로 종료되는 것을 방지한다.

 - 첫 번째 인자는 종료를 기다리는 스레드의 id이다.

 - 두 번째 인자는 pthread_exit()에서 넘겨주는 인자와 같은 값이다. 만약 스레드가 정상적으로 종료되었다면 두 번째 인자에 리턴 코드가 저장되고 스레드가 취소되면 rval_ptr이 가리키는 곳이 PTHREAD_CANCELED가 설정된다.

 - 만약 pthread_create의 두 번째 인자인 pthread_attr_t의 인자로 NULL을 준 경우는 스레드를 join해야한다.

  • pthread_detach() 
int pthread_detach(pthread_t tid);

 - pthread_join과 다르게 메인에서 스레드를 기다리지 않는 함수.

 - 스레드가 종료될 경우 자동으로 자원이 회수된다. (pthread_join을 할 필요 없다)

 - phtread_detach한 경우 pthread_join으로 스레드를 기다릴 수 없다. 즉 joinable하지 않다.

예제

실제로 다중 스레드를 이용하여 모두 공유하는 전역변수를 2개의 스레드가 1씩 증가시키며 출력하는 프로그램이다.

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>

int var = 0;

void *func1(void *arg) { //1번 스레드 실행 함수
	while(var < 10) {
		printf("thread1 : %d\n", ++var);
		sleep(1);
	}
	pthread_exit(NULL); //1번 스레드 종료
}

void *func2(void *arg) { //2번 스레드 실행 함수
	while(var < 10) {
		printf("thread2 : %d\n", ++var);
		sleep(1);
	}
	pthread_exit(NULL); //2번 스레드 종료
}


int main() {
	pthread_t tid1, tid2;

	if(pthread_create(&tid1, NULL, func1, NULL) != 0) { //1번 thread 생성
		fprintf(stderr, "thread create error\n");
		exit(1);
	}

	if(pthread_create(&tid2, NULL, func2, NULL) != 0) { //2번 thread 생성
		fprintf(stderr, "thread create error\n");
		exit(1);
	}

	pthread_join(tid1, NULL); //1번 스레드 자원 회수
	pthread_join(tid2, NULL); //2번 스레드 자원 회수

	return 0;
}

 

결과

위 코드 실행 결과

위 그림처럼 두 개의 스레드가 각각 공유하는 전역 변수를 1씩 증가시키며 출력하는 것을 볼 수 있다. 하지만 이상한 점이 있다. 스레드가 실행되는 순서가 일정하지 않다는 것이다. 이렇게 스레드가 실행되는 순서는 생성되는 순서와 전혀 관계가 없다. 스레드의 실행 순서를 제어하려면 동기화가 필요하다. 동기화는 이후에 다뤄보겠다.

 

개발환경

OS : wsl2 Ubuntu-20.04

gcc : Ubuntu 9.3.0-17ubuntu1~20.04

Linux 환경에서 파일 입출력을 다루는 방법에는 두 가지가 있다. 하나는 저수준 파일 입출력이고 다른 하나는 고수준 파일 입출력이다.

    저수준 파일 입출력은 시스템의 커널에서 제공하는 system call 함수를 사용하는 방법이다. 리눅스에서는 예시로 open, read, write 등등이 있다. 당연히 system call 함수를 이용하기 때문에 고수준 파일 입출력보다 더 빠른 파일 처리가 가능하다. 저수준 파일 입출력의 특징중 하나는 "바이트" 단위로 파일을 다룬다는 것이다. 그렇기 때문에 일반 파일뿐 아니라 특수 파일까지 다룰 수 있다. 여기서 특수 파일은 데이터 전송이나 디바이스 접근에서 사용하는 파일을 말한다. 마지막으로 파일을 다룰 때 handler인 파일 디스크립터를 사용한다.

    고수준 파일 입출력은 fopen, fread, fputs와 같은 표준 입출력 라이브러리를 사용하는 방법이다. 이 방법은 바이트 단위로 입출력하는 저수준 파일 입출력과 달리 버퍼를 이용하여 한번에 파일을 읽고 쓸 수 있다. 고수준 파일 입출력함수는 저수준 파일 입출력을 이용하여 만들어진다. 그렇기 때문에 저수준 파일 입출력이 고수준 파일 입출력보다 빠르다.

    저수준 파일 입출력에서 사용하는 파일 디스크립터는 프로세스가 실행될때 각 프로세스마다 가지고 있는 파일 디스크립터 테이블에 할당되게 된다. 파일 디스크립터 테이블은 파일 디스크립터의 목록을 관리하는 자료구조이다. 파일 디스크립터 테이블에는 현재 이 프로세스에서 오픈한 파일의 목록들을 알 수 있다. 파일 디스크립터 값은 정수형인 integer 값이다. 이 값은 파일 디스크립터에게 부여된 번호이다. 프로세스가 생성될 경우 0, 1, 2번 파일 디스크립터는 자동적으로 할당되게 된다. 0번은 표준입력(STDIN), 1번은 표준출력(STDOUT), 2번은 표준 에러를 나타낸다. 따라서 프로세스가 생성될 경우 시스템에서 파일 디스크립터 테이블에 자동으로 0, 1, 2번을 할당한다. 그 다음 프로그래머가 직접 파일을 열 경우 3번부터 순서대로 할해당 파일에 접근할 수 있는 파일 디스크립터가 부여되게 된다.

    고수준 파일 입출력에서는 파일 포인터를 사용한다. fopen의 리턴값인 FILE형 구조체 변수를 가리키는 포인터를 통해 파일에 접근할 수 있다. FILE 구조체에는 오픈한 파일을 처리할 수 있는 정보가 기록된다.  당연히 fopen을 통해 파일을 열어도 파일 디스크립터는 생성이 된다. 파일 포인터로부터 파일 디스크립터를 알고 싶다면 fileno(FILE*)라는 함수를 통해 파일포인터에 해당하는 파일디스크립터를 얻을 수 있다. 파일 포인터 구조체는 다음과 같다.

typedef struct {
    int cnt; //버퍼에 남은 문자 수
    unsigned char *base; //버퍼 시작 주소
    unsigned char *ptr //버퍼의 현재 포인터 주소
    unsigned flag //파일의 접근 권한
    int fd; //파일 디스크립터
} FILE;

    대표적인 저수준 파일 입출력 함수와 고수준 파일 입출력 함수를 이용하여 간단한 예제를 실행해 보겠다. 아래는 파일을 열고 읽고 쓰는데 필요한 시스템 호출 함수와 표준 입출력 함수이다.

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    //저수준 파일 입출력
    char fdbuffer[10];
    int fd = open("file.txt", O_RDWR);
    read(fd, fdbuffer, 10);
    write(STDOUT_FILENO, fdbuffer, 10);
    close(fd);
    
    printf("\n");
    
    //고수준 파일 입출력
    char fpbuffer[10];
    FILE* fp = fopen("file.txt", "r+");
    fread(fpbuffer, 10, 1, fp);
    fwrite(fpbuffer, 10, 1, stdout);
    fclose(fp);
}

위 코드는 file.txt라는 파일이 있을 때 저수준 파일 입출력 방법과 고수준 파일 입출력 방법으로 각각 10바이트를 읽어와 화면에 출력하는 함수이다. 실행 결과는 다음과 같다

file.txt파일에는 I'm shyeon이라는 길이 10의 문자열이 들어있다. 이때 위의 코드로 파일 디스크립터와 파일 포인터를 이용해 읽어올 경우 위와 같이 나타나게 된다.

이 프로그램은 bash shell script언어를 이용하여 월, 일을 입력받아 해당 날짜가 무슨 요일인지 출력하는 프로그램이다.

1900년 1월 1일이 월요일이기 때문에 그 때부터 입력받은 날짜까지 지난 일 수를 구하여 7로 나누었을 때의 나머지를 통해 요일을 구할 수 있다. 소스코드는 다음과 같다.

#!/bin/bash
echo -n "type month date : "
read month date
let year=2021-1900
let sum=$year*365
let sum+=$year/4
case $month in
	"1") let sum+=0;;
	"2") let sum+=31;;
	"3") let sum+=59;;
	"4") let sum+=90;;
	"5") let sum+=120;;
	"6") let sum+=151;;
	"7") let sum+=181;;
	"8") let sum+=212;;
	"9") let sum+=243;;
	"10") let sum+=273;;
	"11") let sum+=304;;
	"12") let sum+=334;;
esac
let sum+=$date
let sum%=7
case $sum in
	"0")echo "2021년 $month월 $date일은 일요일입니다.";;
	"1")echo "2021년 $month월 $date일은 월요일입니다.";;
	"2")echo "2021년 $month월 $date일은 화요일입니다.";;
	"3")echo "2021년 $month월 $date일은 수요일입니다.";;
	"4")echo "2021년 $month월 $date일은 목요일입니다.";;
	"5")echo "2021년 $month월 $date일은 금요일입니다.";;
	"6")echo "2021년 $month월 $date일은 토요일입니다.";;
esac

우선 올해 연도 2021에서 1900을 뺀 값에 365를 곱한다. 하지만 우리는 2월달이 29일이 되는 윤년도 생각해야한다. 윤년은 4년마다 오기 때문에 1900년부터 구하고자하는 연도를 뺀 값을 4로 나눈 값을 더해준다. 그 다음 입력한 월의 전달까지의 지난 날짜를 더한다. 그리고 입력한 달의 날짜를 더하면 1900년 1월 1일부터 2021년 입력한 날짜까지의 총 일 수가 구해지게 된다. 이 값을 7로 나누어 나누어 떨어질 경우 일요일 나머지가 1씩 증가할 때마다 월~토까지 나오게 된다.

아래 그림은 실행 결과다.

오늘 날짜와 생일, 1월 1일을 실행해 보았다.

 

결과는 wsl2 ubuntu-20.04에서 실행하였다.

+ Recent posts