수업/Network Programming

[Network Programming] 소켓과 표준 입출력(standard I/O)

hw-ani 2023. 5. 29. 12:50

C standard에 정의된 I/O 함수를 사용하면 다음과 같은 장점이 있다. (ex. fprintf)

1. Portability에 좋다.

2. 버퍼링을 통한 성능 향상에 도움이 된다.

 

 

소켓을 만들면 기본적으로 kernel에서 버퍼를 만든다. 그게 위 그림의 우측에 보이는 소켓 버퍼이다.

TCP 소켓은 오류가 발생하면 데이터 재전송도 해야 되고 하니 이런 버퍼가 꼭 필요하다. 그렇다고 UDP 소켓엔 버퍼가 없는건 아니다. 데이터를 보낼 때도 잠시 담아둬야 할 수도 있고, 받을 때도 application에서 읽기 전까지 잠시 담아둬야 할 수도 있으니 UDP 소켓에서도 버퍼는 필요하다.

 

그런데 standard I/O 함수는 내부적으로 응용 계층에서 버퍼를 또 사용한다. 즉, 표준 입출력 함수를 사용하면 위 그림의 좌측에 보이는 것 처럼 추가 버퍼가 생기는 것이다.

소켓을 만들 때 생성되는 버퍼들은 위에서 언급한 것들이 주 목적이지만, 표준 입출력 함수에서 제공하는 버퍼는 성능 향상이 주 목적이다.

 

일반적인 측면에서 standard I/O의 buffering이 주는 장점은 다음과 같다.

1. system call 횟수를 줄인다. buffer를 이용해 한번에 큼직큼직하게 처리해버리니 각 함수의 내부에서 사용하는 system call 횟수도 줄어든다. 덕분에 system call 관련 오버헤드도 줄어든다.

2. 메모리/디스크 접근 횟수를 줄인다. buffer를 이용해 한번에 큼직큼직하게 데이터를 읽거나 쓰기 때문에 비교적 비싼 작업인 메모리/디스크 접근 횟수를 줄일 수 있다. 일단 최대한 많이 읽어와서 버퍼에 담아두고 거기서 조금씩 처리하는 것이다.

3. 사용자가 버퍼 크기를 지정하여 상황에 맞게 최적화 할 수도 있다. (표준입출력 함수의 버퍼를 조절하는 것도 아마 소켓 버퍼 크기 조절할 때처럼 완벽하게 요구사항을 반영해주진 않을듯)

 

socket 버퍼에 standard I/O 버퍼까지 추가됐을 때 장점은 아래와 같다.

1. 전송하는 데이터 총량이 작아진다. 총량이 어떻게 작아지나 싶을 수도 있는데, 데이터를 보낼 땐 항상 헤더라는게 붙기 때문에 데이터를 내보내는 횟수가 많아질수록 손해이다. 표준입출력 함수의 버퍼에 데이터를 꽉 꽉 담아뒀다가 socket 버퍼로 한번에 보내므로, 한 패킷에 들어가는 데이터가 많아지므로 보내는 패킷의 수는 줄어들 것이다.

2. 소켓의 출력 버퍼로 데이터를 이동시키는 횟수가 줄어든다. 소켓의 출력 버퍼로 데이터를 이동시키는 작업은 꽤 시간이 소모되는데, 표준 입출력 함수의 버퍼에 데이터를 쌓았다가 보내므로 그 횟수를 줄일 수 있다.

 

Q. 아래에서 실제로 사용할 때 보면 표준 입출력함수 쓰고나서 무조건 fflush로 버퍼 비우라던데, 그럼 바로 위에서 말한 1번 2번 장점이 의미가 있나? 바로바로 버퍼 비우는데?
A. 그래도 아예 없는 것보단 나을 것 같다. 비록 바로 fflush()를 하더라도, 버퍼 덕분에 아예 버퍼가 없을 때보단 데이터를 더 쌓아서 보낼 것 같다. (버퍼가 없으면 내부적으로 정확하게 어떻게 동작하는진 모르겠다만..)

 

 

버퍼링이 무조건 좋은 건 아니지만, 데이터 양이 많은 경우 buffer를 사용한 쪽이 더 우수한 성능을 보인다.

실제로 표준입출력 함수와 시스템 함수를 가지고 파일에 읽기/쓰기를 진행했을 때, 표준입출력 함수를 사용한 쪽이 좋은 성능을 보였다. 300MB가 넘어가면 차이가 극명하게 드러난다.

 

 

 

> 표준 입출력 함수 사용에 있어서 불편한 점들

1. 양방향 통신이 쉽지 않다. 데이터가 버퍼링되므로 입출력이 동시에 진행되게 하기 쉽지 않다.

2. fflush 함수 호출이 빈번히 등장할 수 있다.

3. socket 생성 시 반환되는건 file descriptor이므로, standard I/O functions을 사용하려면 이를 FILE * type으로 변경해야한다.

 

 

 

fdopen

#include <stdio.h>

FILE * fdopen(int fd, const char * mode);

file descriptor를 FILE * type으로 변환해주는 함수이다.

첫번째 인자에는 변환할 file descriptor, 두번째 인자에는 생성할 FILE 구조체 포인터의 mode 정보를 전달한다.

두번째 인자에는 fopen에 쓰듯이 "w"나 "w+"나 뭐 이런 애들이 올 수 있다.

 

성공 시 변환된 FILE 구조체 포인터, 실패 시 NULL 을 반환한다.

 

 

 

fileno

#include <stdio.h>

int fileno(FILE * stream);

반대로 FILE * type을 file descriptor로 변환해주는 함수이다.

성공 시 변환된 file descriptor, 실패 시 -1 을 반환한다.

 

 

 

 

 

> 활용 (입출력 스트림 분리)

FILE * readfp  = fdopen(sock, "r");
FILE * writefp = fdopen(sock, "w");

이렇게 입력용, 출력용 FILE 구조체 포인터를 애초에 따로 만들어두는게 구현하기 편하고, 입력버퍼/출력버퍼를 구분해서 버퍼링 기능이 향상된다.

각 file pointer에 대해 표준입출력함수를 사용하고 난 후, fflush 함수로 버퍼를 비워주며 사용해야한다. (fputs, fgets 같은거 쓰고 바로 뒤에 fflush 해주기!!)

 

fflush를 입력 스트림에 쓰는건 표준이 아니라고 알고 있긴한데.. implementation에서 그냥 두 경우 다 지원해주나보다.

 

 

※ 문제점

하지만 위처럼 단순하게 입출력 스트림을 분리해서 사용하면 Half-close를 사용할 수 없다.

(이전에 MultiProcess 글에서 했던 입출력 스트림 분리에선 그냥 shutdown 함수 쓰면 Half-close됨.)

fclose(writefp);

위 코드로 Half-close를 할 수 있을 것 같지만, writefp나 readfp나 같은 file descriptor를 공유하므로 위 코드가 실행되면 둘 다 종료 돼버린다.

 

 

 

위 그림에서 윗쪽 경우처럼 하나의 File Descriptor로 FILE 포인터가 만들어졌기 때문에 Half-close가 안되는 것이다.

따라서 아래 경우처럼 특정 socket에 대한 File Descriptor를 복사한 뒤, 각각에 대해 FILE 포인터를 만들면 FILE 포인터 소멸 시 해당 FILE 포인터에 연결된 File Descriptor만 소멸된다.

하지만 아래처럼 하더라도 아직 완전한 Half-close는 아니다. 왜냐하면 socket의 input 혹은 output stream이 닫혀야 상대방에게 종료 요청이 전달되고, 그래야 Half-close이다. 아래 그림처럼 write 관련 FILE 포인터를 닫더라도, 여전히 우리는 socket을 직접 사용해 write를 진행할 수 있다.(아래에서 해결책 제시)

 

 

 

dup & dup2

#include <unistd.h>

int dup(int fd);
int dup2(int fd, int fd2);

두 함수 모두 첫번째 인자로 복사할 File descriptor를 전달한다. dup2 함수는 두번째 인자로 명시적으로 복사할 File descriptor 값을 지정한다.

성공 시 복사된 File descriptor, 실패 시 -1 을 반환한다.

 

 

 

 

> Half-close까지 지원하는 예시 코드 (+ 표준 I/O로 입출력 스트림 분리)

//reading용 FILE 포인터
FILE * readfp  = fdopen(clnt_sock, "r");

//writing용 FILE 포인터, dup()으로 fd 복사 후 진행
FILE * writefp = fdopen(dup(clnt_sock), "w");

fputs(". . .", writefp); fputs(". . .", writefp);
fflush(writefp);

//이렇게 직접 File Descriptor로 shutdown 함수까지
//호출해줘야 제대로 Half-close가 된다.
shutdown(fileno(writefp), SHUT_WR);
fclose(writefp); //출력용 FILE 포인터도 없애기

fgets(buf, sizeof(buf), readfp);
fclose(readfp);  //완전 종료