본문 바로가기
42seoul

Minitalk : 서버와 클라이언트 간 문자열 교환 프로그램(using SIGNAL)

by objet 2022. 4. 2.
프로젝트 개요

Mandatory Part

클라이언트와 서버가 서로 통신하는 프로그램을 작성하라.

서버가 먼저 실행되어야 하며, 서버는 실행된 후 반드시 자신의 PID를 표시하라.

클라이언트는 다음과 같이 실행한다. (서버 PID와 전송할 문자열, 두 매개변수와 같이 실행되어야 한다.)

 

클라이언트는 매개변수로 전달한 문자열을 서버로 통신해야 하며, 서버는 문자열이 수산되면 해당 문자열을 표시해야 한다.

오로지 UNIX SIGNAL을 이용해서 통신을 구현하라!

서버는 문자열을 매우 빠른 속도로 표시할 수 있어야한다. 예: 길이가 100인 문자열을 표시하는 데에 1초가 걸린다면 너무 긴 것이다.

서버는 재시작할 필요 없이 여러 클라이언트로부터 문자열을 연속으로 수신할 수 있어야 한다.

SIGUSR1과 SIGUSR2 두 시그널만 사용하라.

 

Bonus Part

소규모 수신 확인 시스템(small reception acknowledgement system)을 추가해보자.

유니코드 문자도 지원하도록 하자.

유니코드 예시: 🖤∑∞☞★⚝✅🔥⌚☣☮🌏📱🚀🍔🍦👑🎵🎧


프로세스 간의 통신 (inter-process communication, 이하 IPC)

프로세스는 여러가지 필요에 의해 통신을 한다.

IPC는 주고받는 데이터 크기가 매우 다양하다.

프로세스가 동일한 시스템에 있을 수도 있고, 다른 시스템 상에 있을 수도 있다.

=> 우리가 구현하려는 건 내부 IPC, 동일한 시스템 내의 두 프로세스 간의 통신.

 

IPC에는 시그널 뿐만 아니라 파이프(pipe), 소켓(socket)을 사용하여 구현 할 수 있다.

 

시그널 (SIGNAL)

시그널이란?

보편적으로 인터럽트(interrupt)라고 부르며, 특정 이벤트가 발생했을 때, 프로세스에게 전달하는 신호이다.

특정 이벤트라 함은 연산 오류 발생, 자식 프로세스의 종료, 사용자의 종료 요청 등 여러가지 경우가 있을 수 있다.

 

30여개의 시그널이 있으며, 각각을 구분하기 위하여 번호를 부여하였다.

프로그램에서는 심볼릭 상수를 사용한다. (예: # define SIGINT 2)

- Ctrl + C를 누를 경우 SIGINT(2번) 시그널이 발생한다.

- Ctrl + \를 누를 경우 SIGQUIT(3번) 시그널이 발생한다.

- kill 명령을 사용하면 SIGTERM(15번) 시그널이 발생한다.

 

다음은 시그널의 종류이다.

이름
설명
DA (Default Action)
SIGABRT
abort()를 호출할 때 발생
TC (Terminate with Core)
SIGALRM
설정된 알람 시간이 경과한 경우 발생
T (Terminate)
SIGCHLD
자식 프로세스가 일시 중단되거나 종료될 때 발생
IGN(Ignore)
SIGINT
Ctrl + C를 눌렀을 때 발생
T (Terminate)
SIGKILL
프로세스를 강제 종료시킬 때 사용
T (Terminate)
SIGPIPE
파이프 reader가 종료되었는데 write하는 경우 발생
T (Terminate)
SIGQUIT
Ctrl + \를 눌렀을 때 발생
TC (Terminate with Core)
SIGSEGV
잘못된 메모리 참조 시 발생
TC (Terminate with Core)
SIGUSR1
사용자가 임의 목적으로 사용
T (Terminate)
SIGUSR2
사용자가 임의 목적으로 사용
T (Terminate)

Terminate with Core는 Core라는 파일을 생성하고 종료함을 의미한다.

 

프로세스에 시그널을 보내는 방법

① 키보드로부터 시그널을 전달하는 경우 : Ctrl + C, Ctrl + \를 누르면 foreground 프로세스에 SIGINT, SIGQUIT시그널이 전달됨.

* foreground process = 프로세스의 실행이 끝날 때까지 사용자가 다른 입력을 하지 못하는 프로세스

background process = 사용자 입력과 상관없이 실행되는 프로세스

 kill 명령을 이용하여 시그널을 전달하는 경우 : 대표적인 예로 kill 1234는 SIGTERM 시그널을 전달하는 명령이다. 

③ 시스템에 의해 프로세스에 시그널이 전달되는 경우 : SIGALRM, SIGPIPE, SIGSEGV 등 ...

 

시그널을 수신한 프로세스의 반응은 세가지로 나뉜다.

- catch signal : 사용자가 설정할 수 있다. 프로그램에서 지정된 시그널 핸들러가 호출된다.

- ignore signal : 사용자가 설정할 수 있다. 단, SIGKILL와 SIGSTOP은 ignore와 catch 불가

- defaullt action : 아무런 설정도 하지 않은 경우 DA가 실행되며 대개는 프로세스가 종료된다.

 

 

시그널 핸들러(signal handler)

출처 입력

시그널 핸들러는 프로세스가 시그널을 받았을 때 호출되는 함수이다.

프로세스가 시그널을 받으면 수행하던 작업이 일시 중지되고 시그널 핸들러가 호출된다.

시그널 핸들러의 실행이 끝나면 일시 중지된 작업이 재개된다.

#include <signal.h>

void (*signal(int signo, void (*func) (int))) (int);
 

signo : 시그널 번호

func : void f(int)와 같은 형태의 함수에 대한 포인트

return value : 호출에 성공하면 이전에 설정된 disposition을 반환하고, 실패하면 SIG_ERR를 반환한다.

 

※ void ( *f ) (int) 형식 => 이는 f라는 포인터 변수를 선언한 것.

여기서 f는 int 타입의 인자를 가지는 함수를 가리키는 포인터 변

수이다.

함수의 프로토타입이 아님에 주의하라! 

void *f(int); vs void (*f)(int); => 함수 프로토타입 vs 포인터 변수 선언

 

즉, signal 함수의 return type은, int형 변수 하나를 인자로 가지는 함수의 시작점을 가리키는 포인터 변수인 것.

 

예) signal() 함수를 사용하여 Ctrl + C를 눌렀을 때, 즉 SIGINT가 시스템에 의해 프로세스에 보내졌을 때 시그널 핸들러 f()가 호출되는 프로그램

#include <stdio.h>
#include <signal.h>

void f(int signo);
int counter = 0;

int main()
{
	signal(SIGINT, f);
	while (counter < 10)
	{
		printf("Die hard\n"); sleep(1);
	}
	printf("I am dying");

	void f(int signo)
	{
		printf("signo = %d, counter = %d\n", signo, counter);
		counter++;
	}
}
 

 

시그널과 관련된 시스템 콜
시스템 콜(system call)
의미
sigemptyset
시그널 집합을 공집합으로 만든다.
sigfillset
시그널 집합을 모든 시그널이 포함된 전체 집합으로 만든다.
sigaddset
시그널 집합에 특정 시그널을 추가한다.
sigdelset
시그널 집합에서 특정 시그널을 제외시킨다.
sigaction
특정 시그널에 대한 프로세스의 행동을 설정한다.
sigprocmask
block할 시그널의 목록을 변경한다. (차단목록)
kill
특정 프로세스에게 특정 시그널을 보낸다.
raise
자기 자신에게 특정 시그널을 보낸다.
alarm
설정된 시간이 경과한 후 SIGALRM 시그널을 받는다.
pause
시그널이 도착할 때까지 대기 상태가 된다.

 

① 시그널을 다루기 위해서는 시그널 집합이 필요 : sigemptyset, sigfillset

#include <signal.h>

int sigemptyset(sigset_t *set);  // 아무런 시그널도 포함하지 않는 시그널 집합을 만든다.
int sigfillset(sigset_t *set);   // 모든 시그널을 포함하는 시그널 집합을 만든다.
 

인자 set : sigset_t 타입의 시그널 집합

return value : 호출에 성공하면 0을 반환하고, 실패하면 -1을 반환.

 

② 시그널 집합을 Control : sigaddset, sigdelset, sigismember

#include <signal.h>

int sigaddset(sigset_t *set, int signum);         // 지정한 시그널을 시그널 집합에 추가한다.
int sigdelset(sigset_t *set, int signum);         // 지정한 시그널을 시그널 집합에서 제거한다.
int sigismember(const sigset_t *set, int signum); // 시그널 집합에 지정한 시그널이 포함되어 있는지 검사
 

첫번째 인자 set : sigset_t 타입의 시그널 집합

두번째 인자 signum : 지정할 시그널 번호

return value : 호출에 성공하면 0을 반환하고, 실패하면 -1을 반환. 단, sigismember()는 호출 성공 시 1 또는 0을 반환하고 실패하면 -1을 반환.

 

③ 시그널을 받았을 때 프로세스가 취할 행동을 지정 : sigaction

#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
 

첫번째 인자 signum : 지정할 시그널 번호. 단, SIGKILL과 SIGSTOP은 적용할 수 없다.

두번째 인자 act : 시그널에 대해서 취할 행동에 관한 정보가 담겨있다. 자세히 보니 타입이 const struct sigaction이다.

sigaction이라는 이름의 구조체에 대해서는 다음 단락에 자세히 서술할 것이다.

세번째 인자 oldact : 이전에 설정되어 있던 시그널에 대한 행동에 관한 정보를 가지고 온다. 이 정보가 필요없다면 NULL값을 준다.

return value : 호출에 성공할 경우 0을 반환, 실패할 경우 -1을 반환.

=> signum으로 지정한 시그널에 대해 act로 지정한 행동을 취한다.

새로운 행동인 act가 등록되면서 기존의 행동은 oldact를 통해 반환된다.

 

이번엔 act 인자의 타입이었던 sigaction 구조체에 대해 살펴보자. sigaction의 구조는 다음과 같다.

struct sigaction {
	void (*sa_handler)(int);
	sigset_t sa_mask;
	int sa_flags;
};
 

- 첫번째 멤버 void (*sa_handler)(int) : 앞에서 설명한 함수 포인터 변수와 같은 형식. 

int형 인자 하나를 가지고 void형인 함수를 가리키는 포인터 변수.

SIG_DFL
Default Action. 기본적으로 설정된 행동. 대개는 프로세스가 종료됨
SIG_IGN
시그널을 무시한다. (단, SIGSTOP과 SIGKILL은 ignore 및 catch 불가)
핸들러 주소
사용자가 정의한 시그널 핸들러에 대한 주소 ★★

 

- 두번째 멤버 sa_mask : block시킬 시그널을 집합으로 나타낸다. sa_mask에 속하는 시그널은 시그널 핸들러가 실행되는 동안 block된다.

* block이란? 

프로세스에 시그널이 전달되는 것을 일시적으로 보류하는 것을 의미.

block되어 프로세스에 전달되지 못하고 일시보류된 시그널을 pending되어 있다고 한다.

현재 처리중인 시그널 또한 block시킬 수 있다.

 

- 세번째 멤버 sa_flags : 시그널 처리에 대한 옵션을 설정한다.

의미
SA_NOCLDSTOP
자식 프로세스가 일시 중단되었을 때 SIGCHLD가 발생되지 않도록 한다.
단, 자식 프로세스가 종료되면 SIGCHLD가 발생함.
SA_RESETHAND
시그널 핸들러 호출 시 SIG_DFL로 reset 한다.
SA_SIGINFO
sa_flags에 이 값을 지정할 경우 시그널의 발생 원인을 알 수 있으며, 
sigaction 구조체의 첫번째 멤버인 sa_handler 대신 sa_sigaction을 사용할 수 있게 됨.

 

여기서 잠깐! sa_handler vs sa_sigaction

두 구조는 다음과 같이 정의된다.

// sa_handler
void     (*sa_handler)(int);

// sa_sigaction   
void     (*sa_sigaction)(int, siginfo_t *, void *);  
 

sa_handler는 int형 인자 하나만을 가지는 void형 핸들러 함수를 가리키는 포인터 변수이며,

sa_sigaction은 총 세개의 인자(int형 하나, siginfo_t* 하나, void* 하나)를 가지는 void형 핸들러 함수를 지정 할 수 있는 포인터 변수이다.

 

한눈에 보이다시피 sa_sigaction을 쓰면 더 많은 정보를 함수 내에서 처리할 수 있게 된다.

두 변수는 선언할 시 메모리 공간이 서로 중첩되므로 무조건 둘 중 하나만 쓸 수 있다.

 

+ siginfo_t 이란? 

다음과 같이 정의되어있는 구조체이다.

siginfo_t {
      int      si_signo;  /* 시그널 넘버 */
      int      si_errno;  /* errno 값 */
      int      si_code;   /* 시그널 코드 */
      pid_t    si_pid;    /* 프로세스 ID 보내기 */
      uid_t    si_uid;    /* 프로세스를 전송하는 실제 사용자 ID */
      int      si_status; /* Exit 값 또는 시그널 */
      clock_t  si_utime;  /* 소모된 사용자 시간 */
      clock_t  si_stime;  /* 소모된 시스템 시간 */
      sigval_t si_value;  /* 시그널 값 */
      int      si_int;    /* POSIX.1b 시그널 */
      void *   si_ptr;    /* POSIX.1b 시그널 */
      void *   si_addr;   /* 실패를 초래한 메모리 위치 */
      int      si_band;   /* 밴드 이벤트 */
      int      si_fd;     /* 파일 기술자 */
  }
 

시그널이 발생된 원인과 그에 대한 정보를 담고 있다.

 

④ block 시킬 시그널을 설정 : sigprocmask

이 내용은 minitalk을 수행하는 데 불필요한 내용이므로 후에 서술할 예정.

 

⑤ 특정 프로세스에 시그널 전송 : kill, raise

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

int kill(pid_t pid, int sig); // 특정 프로세스나 프로세스 그룹에 지정한 시그널을 보낸다.
int raise(int sig);           // 자기 자신에게 지정한 시그널을 보낸다.
 

첫번째 인자 pid : 프로세스의 식별 번호이다. 현재 실행하고 있는 프로세스에서 getpid()함수를 통해 해당 프로세스의 pid를 알 수 있다.

pid의 값에 따라 같은 그룹에 속하는 프로세스에게 동시에 시그널을 보낼 수도 있고, 개별적으로 보낼 수도 있다.

pid
의미
pid > 0
지정할 프로세스 식별번호. 
pid = 0
자신과 같은 그룹에 있는 모든 프로세스에게 시그널을 보낸다.
pid = -1
POSIX.1 leaves this condition as unspecified. 
pid < -1
pgid가 |pid|인 모든 프로세스에게 시그널을 보낸다.
즉, pid가 -100이라면 pgid가 100인 모든 프로세스에게 시그널을 보낸다.

두번째 인자 sig : 시그널 번호이다.

return value : 호출에 성공하면 0을 반환, 실패하면 -1을 반환.

 

++) 재미있는 예시

raise(sig) = kill(getpid(), sig);
 

해당 식이 성립할까? raise와 kill이 공존할 수 있을까? 정답은 yes이다.

 

 

⑥ 시그널이 올 때까지 대기 : pause

#include <unistd.h>

int pause(void);
 

return value : 항상 -1을 반환.

pause()를 호출한 프로세스는 임의의 시그널이 올 때까지 대기한다.

- 수신된 시그널이 프로세스를 종료시키는 것이라면 프로세스는 pause()로부터 return 하자마자 종료된다.

- 시그널 핸들러가 등록된 시그널이라면 시그널 핸들러를 실행하고 나서 pause()로부터 return된다.

- 무시하도록 설정된 시그널에 대해서는 반응하지 않는다.

 

⑦ 코드 실행 보류 : sleep, usleep

이번엔 시그널에 관한 함수는 아니지만 minitalk 과제를 수행하는 데 유용했던 함수를 정리해보았다.

#include <unistd.h>

unsigned int sleep(unsigned int seconds); // 단위가 초
int usleep(useconds_t usec);              // 단위가 마이크로초
 

코드에 sleep(10)을 쓸 경우 다음 흐름의 코드는 10초 후에 수행된다.

이와 같이, usleep(10000000)을 할 경우 똑같이 다음 흐름의 코드가 10초 후에 수행된다.

usleep은 초 단위를 더욱 세밀하게 조절하기 위해 사용한다.

 

SIGALRM과 pause()를 이용하여 sleep함수를 다음과 같이 간단하게 구현할 수도 있다.

#include <signal.h>
#include <unistd.h>

static void sig_alrm(int signo)
{
	return;
}

int sleep(int nsecs)
{
	if (signal(SIGALRM, sig_alrm) == SIG_ERR) return nsecs;
	alarm(nsecs);
	pause();
	return alarm(0);
}
 

 
minitalk의 흐름

minitalk에서는 오로지 SIGUSR1과 SIGUSR2, 이 두 시그널만 쓸 수 있다.

시그널은 오로지 bit 신호로 전송되기만 할 뿐, 값을 전달할 수 없기 때문에 bit로 값을 얻어내는 처리를 해주어야 한다.

 

클라이언트에서 문자열을 보낸다면, 이를 문자 하나로 나누어야하고 문자 하나(8bit)를 1bit로 또 나누는 작업이 필요하다.

작성한 코드에서 ft_send_message()함수를 통해 문자열을 문자로 나누고,

ft_send_byte()함수를 통해 bit하나씩 분할한다. 

0일 경우 SIGUSR1을 서버에게 보내고, 1일 경우 SIGUSR2를 서버에게 보내어 서버가 각 bit들을 구별하도록 한다.

 

서버 코드에서는 sigaction으로 시그널을 받았을 때 핸들러인 ft_message_handler()를 호출하도록 설정해주었다.

메세지 핸들러에서 client의 pid정보를 저장하고, bit단위로 전송된 정보들을 다시 문자열로 만들기 위한 ft_build_message()함수를 호출한다.

8비트까지 다 조합이 완성되면 문자열을 출력하고 연속해서 문자열을 다시 전송받기 위해 초기화를 한다.

 

보너스 구현

유니코드는 가변 길이 문자 인코딩 방식인 UTF-8 방식을 사용하는데, 이는 최소 1byte부터 4byte의 크기를 가변적으로 사용하게 된다.

즉 문자열이 1byte니까 size에 따라 네 번 더 출력해주면 되는 것

 

확인 시스템 매커니즘은 다음과 같다.

필수 파트는 client가 일방적으로 server에게 시그널 두개를 보내는 거였다면,

이번에는 server가 문자조합 잘 된다고 client에게 시그널을 보내는 것.

나는 문자조합이 되면 SIGUSR2를 client에게 보내주기로 했다.

server_bonus코드의 ft_build_message함수에서 8개의 비트를 전부 조합한 경우 

kill명령을 이용해 핸들러에서 저장해놓은 client pid로 SIGUSR2를 전송시켜주었다.

서버로부터 오는 시그널을 캐치하기 위해 client_bonus 코드에 핸들러 함수를 하나 더 추가해주었고 메인 함수에 signal()함수를 사용하였다.

 

구현 끝!