Home [Linux System Programming] Ch03 버퍼 입출력
Post
Cancel

[Linux System Programming] Ch03 버퍼 입출력

[Ch03 버퍼 입출력]

블록 : 파일 시스템의 최소 저장 단위를 나타내는 추상 개념. 파일 시스템 연산은 블록 단위로 일어난다. (데이터에 필요한 블록이 4.5개라 하더라도 5개를 써야함.)

→ 블록의 일부분만 다루는 연산이 비효율적임.

3.1 사용자 버퍼 입출력

  • 일반 파일에 대해 잦은 입출력을 처리해야만 하는 프로그램은 종종 사용자 버퍼 입출력을 수행한다.
  • 이는 커널이 아니라 사용자 영역에서 버퍼링을 처리한다는 의미

    커널은 내부적으로 지연된 쓰기연산, 미리읽기, 연속된 입출력 요청 을 모아서 처리하는 방식으로 버퍼링을 구현하고 있음.

Untitled

일반 파일에 대해 잦은 입출력을 처리해야 하는 프로그램은 종종 사용자 버퍼 입출력을 수행한다.

1
dd bs=1 count=2097152 if=/dev/zero of=pirate

2MB 데이터를 1B씩 약 2백만 번에 걸쳐 읽어들임

1
dd bs=1024 count=2048 if=/dev/zero of=pirate

2MB 데이터를 1KB씩 약 2천 번에 걸쳐 읽어들임

블록 크기 (Byte)실제 시간 (초)사용자 시간 (초)시스템 시간 (초)
118.7071.11817.549
10240.0250.0020.023
11300.0350.0020.027

1KB 단위로 읽어들이면 시스템콜의 횟수를 1024배 줄임으로써 성능을 비약적으로 개선할 수 있다.

다만 블록 크기를 1130 Byte로 키우면 시스템콜의 횟수는 줄지만 실제 물리 블록의 크기의 약수나 배수가 아니므로 성능 저하가 발생한다. 실제로 /dev/zero의 경우 블록 크기는 4096 Byte다.

3.1.1 블록크기

  • 실제로 블록 크기는 보통 512, 1024, 2048, 4096 혹은 8192로 정해진다.
  • → 커널과 하드웨어는 블록 크기를 기준으로 대화하기 때문에 블록 크기의 정수배약수 단위로 연산을 수행하기만 해도 상당한 성능 개선이 따라옴
  • 그렇다면 모든 데이터를 4KB or 8KB단위로 취급하는게 좋은가?
    • No. 실제로 데이터를 블록 단위로 취급하는 프로그램이 드물기에 현실성이 없음
  • 프로그램은 블록 같은 추상 개념이 아니라 필드, 행, 단일 문자를 다룬다. 그래서 사용자 버퍼 입출력이 필요함.
1
2
3
4
데이터가 쓰여지면 프로그램 **주소 공간 내 버퍼**에 저장이 됨.
버퍼가 특정 크기에 도달하면 전체 버퍼는 **한 번의 쓰기 연산을 통해 실제로 기록이 됨.**

읽기 또한 버퍼 크기에 맞춰 **블록에 정렬된 데이터를 읽는다.**

→ 데이터가 많더라도 모두 블록 크기에 맞춰 적은 횟수의 시스템 콜만 사용하게 됨. 성능 향상!

사용자 애플리케이션 코드 레벨에서 인위적으로 버퍼링을 구현해서 사용해야함.

But, 표준 입출력 라이브러라 (stdio)와 표준 C++ iostream이라는 견고하고 뛰어난 사용자 버퍼링 구현체를 가져다 사용하면 된다!

3.2 표준 입출력

  • 표준 C 라이브러리는 표준 입출력 라이브러리 (stdio)를 제공함

3.2.1 파일 포인터

  • 표준 입출력 루틴은 File Descriptor를 직접 다루지 않고, File pointer라는 독자적인 식별자를 사용한다.
  • 표준 입출력 용어로 열린 파일은 Stream 이라고 부르기도 함.
    • Stream 은 읽기(입력 스트림), 쓰기 (출력 스트림), 또는 읽기/쓰기 (입출력 스트림) 모드로 열 수 있음 Untitled

3.3 파일 열기

  • 파일을 읽거나 쓰기 위해서 fopen()을 사용한다.
  • (FILE은 stdio.h 에 정의된 FILE typedef)
1
2
3
4
5
6
7
8
FILE * fopen (const char *path, const char *mode)

// EX
FILE *stream;

stream = fopen ("/etc/manifest", "r");
if (!stream)
	ERROR
  • 파일 path를 mode에 따라 원하는 용도로 새로운 스트림을 만든다.
  • 성공 시 유효한 FILE 포인터를 반환.
  • 실패 시 NULL 반환, errno 설정

3.3.1 모드

  • r : 읽기 목적으로 파일을 엶.
  • r+: 읽기/쓰기 목적. 스트림은 파일 시작 지점.
  • w : 쓰기 목적으로 파일을 엶. 파일이 이미 존재하면 길이를 0으로 잘라버림. 파일이 존재하지 않으면 새로 만듬.
  • w+: 읽기/쓰기 목적. 파일이 이미 존재하면 길이를 0으로 자름. 파일이 존재하지 않으면 새로 만듦. 스트림은 파일 시작 지점.
  • a : 덧붙이기 상태에서 쓰기 목적으로 파일을 엶.
  • a+: 덧붙이기 상태에서 읽기/쓰기 목적으로 파일을 엶. 파일이 존재하지 않으면 새로 만듦. 스트림은 파일 끝 지점.

3.4 파일 디스크립터로 스트림 열기

  • fdopen() 함수는 이미 열린 파일 디스크립터를 통해 스트림을 만든다.
1
FILE * fdopen (int fd, const char *mode);
  • 사용가능한 mode는 fopen()과 동일하며, 원래 fd를 열 때 사용했던 모드와 호환성을 유지해야 한다.
    • fopen()에서는 w모드로 스트림을 열었을 때 이미 존재한다면 파일을 0으로 잘라버렸음. 하지만 fdopen()은 그렇지 않은데 그 이유는 이미 파일이 fd에 대해서 열려있기 때문.
    • 따라서 open() 함수에 의해 반환 받은 fd를 fdopen() 함수에서 받았을 경우 open() 함수에 O_TRUNC 플래그가 있어야만 파일을 자를 수 있음!
  • fd가 스트림으로 변환되면 그 fd를 통해 직접 입출력을 수행이 가능하긴 하지만 그렇게 하면 안됨!

3.5 스트림 닫기

1
int fclose (FILE *stream)
  • 버퍼에 쌓여있지만 아직 스트림에 쓰지 않은 데이터를 먼저 처리함.
  • fclose 하면 fd까지 닫히나?
  • 성공하면 0 반환, 실패하면 EOF 반환하고 errno 적절한 값으로 설정

3.5.1 모든 스트림 닫기

1
int fcloseall (void)
  • fcloseall() 함수는 stdin, sdtout, stderr 를 포함해서 현재 프로세스와 관련된 모든 스트림을 닫는다.
  • 닫기 전에 버퍼에 남아 있는 데이터는 모두 스트림에 쓰여지며 언제나 0을 반환

3.6 스트림에서 읽기

  • 스트림에서 데이터를 읽으려면 w나 a를 제외한 나머지 모드(읽기 가능 모드)로 스트림을 열어야 함

3.6.1 한 번에 한 문자씩 읽기

1
int fgetc(FILE *stream)
  • stream 에서 다음 문자를 읽고 unsigned char 타입을 int 타입으로 변환해서 반환한다.
    • 타입 변환 이유 : 파일 끝이나 에러를 알려줄 수 있도록 하기 위함. 이런 에러일 때는 EOF반환
    • 반드시 반환 값이 int 타입이어야 한다. char타입으로 저장하게 되면 에러 확인이 불가능함!
1
2
3
4
5
6
7
int c;

c = fgetc (stream);
if (c == EOF)
	// error
else
	printf()

읽은 문자 되돌리기

  • 스트림을 찔러보고 원하는 문자가 아닌 경우 되돌려버린다.
  • 즉, 스트림에 문자를 다시 집어넣는 것임.
1
int ungetc (int c, FILE *stream)
1
2
3
4
5
6
7
8
9
// 여러번 호출 시 역순으로 출력. LIFO (Last In First Out)
// 파일에 직접 쓰여지는 것이 아니라 버퍼에 쓰여지게 됨
// 리눅스에서는 메모리가 허용하는 범위 내에서 무제한 되돌리기 허용

ungetc('a', stream);
ungetc('b', stream);

ch = getc(fp);  // ch 에는 b 가 들어간다.
ch = getc(fp);  //  ch 에는 a 가 들어간다.
1
2
3
4
5
6
7
8
9
10
// 중간에 파일 위치 표시자의 값이 0이 된다면 그 이후에 호출된 unget함수들은 모두 무시됨.

fp = fopen("test.txt", "r");
getc(fp);         // 이 함수 호출 이후 위치 표시자의 값은 1
ungetc('a', fp);  // 이 함수 호출 이후 값은 0
ungetc('b', fp);  // 따라서 버퍼에 b 가 들어갈 수 없다.

ch = getc(fp);  // ch 에는 a 가 들어간다.
printf("%c", ch);
ch = getc(fp);  //  ch 에는 test.txt 의 두 번째 문자가 들어간다.
  • ungetc()를 호출하고 중간에 탐색함수를 호출했고, 읽기 요청은 아직 하지 않았을 경우 되돌린 문자를 다 버린다.
    • 스레드는 버퍼를 공유하므로 단일 프로세스에서 여러 스레드가 동작하는 경우에도 동일한 현상 발생

3.6.2 한 줄씩 읽기

  • fgets() 함수는 stream에서 문자열을 읽는다.
1
char *fgets (char *str, int size, FILE *stream)
  • stream에서 size보다 하나 적은 내용을 읽어서 결과를 str에 저장한다.
  • 마지막 바이트를 읽고 난 다음, 버퍼 마지막에 null 문자 (\0)을 저장한다.
  • EOF나 개행문자를 만나면 읽기 중단. 개행문자를 읽으면 str에 \n을 저장
    • 무조건 \0은 마지막에 넣음.
    • 문자열은 마지막에 NUll로 끝남
  • 성공하면 str을 반환, 에러일 경우 NULL 반환

원하는 만큼 문자열 읽기

  • 행 단위로 읽는 방법은 유용하지만 다른 구분자를 사용하고 싶을 때도 있음
  • fgetc 로 fgets와 동일한 로직을 구현할 수 있다
1
2
3
4
5
6
7
8
9
10
char *s;
int c;

s = str
// n-1 바이트를 읽어서 str에 저장
while (--n > 0 && (c = fgets (stream)) != EOF)
	*s++ = c;

// \0 을 추가
*s = '\0';
1
2
3
4
5
6
7
8
9
// d를 \n으로 하면 fgets와 동일
while (--n > 0 && (c = fgec (stream) != EOF && (*s++ = c) != d)
	;

if (c==d)
	*--s = '\0';
else
	*s = '\0';

3.6.3 바이너리 데이터 읽기

  • 개별 문자나 행을 읽는 기능만으로 부족할때 (C 구조체 같은 복잡한 바이너리 데이터를 읽고 써야하는 경우) fread()함수 사용
1
size_t fread( void *buf, size_t size, size_t nr, FILE *stream)
  • stream에서 각각 크기가 size 바이트인 엘리먼트를 nr개 읽어서 buf가 가리키는 버퍼에 저장한다.
  • 읽어들인 엘리먼트 개수가 반환됨.
  • nr보다 적은 값을 반환하여 실패나 EOF를 반환
    • ferror() or feof()를 사용하지 않고서는 실패 or EOF를 알 수가 없음
  • 변수의 크기, 정렬, 채워넣기, 바이트 순서가 다르기 때문에 어떤 애플리케이션에서 기록한 바이너리 데이터를 다른 앱에서는 못 읽을 수도 있다.

정렬문제

  • 모든 아키텍처는 데이터 정렬 요구사항을 가지고 있음.
  • 프로세스는 바이트 크기 단위로 메모리를 읽고 쓰지 않고, 2,4,8,,, 바이트처럼 정해진 기본 단위로 메모리에 접근함. → 기본 단위의 정수배로 시작하는 주소에 접근해야함
  • 따라서 C언어에서 변수는 반드시 정렬된 주소에 저장하고 접근해야함.
    • 예를 들어 32비트 정수는 4바이트 경계에 맞춰 정렬됨. → int는 4로 나누어 떨어지는 메모리 주소 공간에 저장된다.
  • 정렬되지 않은 데이터 접근에 대해서는 다양한 패널티가 존재한다.
    • 접근 가능 but 성능 저하
    • 접근 허용 X, 하드웨어 예외로 처리
    • 강제 정렬을 위해 하위 비트를 제거해버림

3.7 스트림에 쓰기

3.7.1 한 번에 문자 하나만 기록하기

  • fgetc()에 대응하는 쓰기 함수는 fputc()이다.
1
int fputc(int c, FILE *stream);
  • c로 지정한 바이트를 (unsigned char로 변환한 후에) stream이 가리키는 스트림에 쓴다.
    • 문자 혹은 숫자가 아스키 코드표에 맞게 int값으로 들어감.
  • 성공 시 c 반환, 실패 시 EOF 반환하고 errno 설정
1
if (fputc ('p', stream) == EOF)

3.7.2 문자열 기록하기

1
int fputs (const char *str, FILE *stream)
  • str이 가리키는 NULL로 끝나는 문자열 전무를 stream이 가리키는 스트림에 기록한다.
  • 성공하면 음수가 아닌 값 반환, 실패 시 EOF 반환

3.7.3 바이너리 데이터 기록하기

  • C 변수처럼 바이너리 데이터를 직접 저장하려면 표준 입출력에서 제공하는 fwrite()를 사용
1
2
3
4
size_t fwrite (void *buf,
							size_t size,
							size_t nr,
							FILE *stream);
  • buf가 가리키는 데이터에서 size크기의 엘리먼트 nr개를 stream에 쓴다.

3.8 사용자 버퍼 입출력 예제 프로그램

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int main(void)
{
  FILE *in, *out;
  struct pirate {
    char    name[100];
    unsigned long   booty;
    unsigned int    beard_len;
  } p, blackbeard = {"Edward Teach", 950, 48};

  out = fopen ("data", "w");
  if (!out) {
    perror("fopen");
    return 1;
  }

  if (!fwrite(&blackbeard, sizeof(struct pirate), 1, out)){
    perror ("fwrite");
    return 1;
  }

  if (fclose(out)){
    perror("fclose");
    return 1;
  }

  in = fopen("data", "r");

  if (!fread(&p, sizeof (struct pirate), 1, in)){
    perror("fread")
    return 1;
  }
}

변수 크기, 정렬 등에서 차이가 있기 때문에 특정 애플리케이션에서 쓴 바이너리 데이터를 다른 애플리케이션에서 읽지 못할 수도 있다.

만약 unsigned long 타입의 크기가 바뀌거나 채워 넣는 값의 양이 달라진다면 정확한 데이터를 못쓸것. 아키텍처와 ABI가 동일한 경우에만 바이너리 데이터를 일관적으로 읽고 쓸 수 있음.

  • ABI : Application Binary Interface

3.9 스트림 탐색하기

1
int fseek (FILE *stream, long offset, int whence)
  • offsetwhence에 따라 stream에서 파일 위치를 조작한다.
  • whence
    • SEEK_SET - 파일 위치를 offset값으로 설정
    • SEEK_CUR - 현재위치에서 offset만큼 더한 값으로 설정
    • SEEK_END - 파일 위치를 파일 끝에서 offset만큼 더한 값으로 설정
  • 성공하면 0 반환하고 EOF 지시자를 초기화하고 이전에 실행했던 ungetc()를 취소한다.
  • 에러 발생하면 -1 반환하고 errno를 설정
    • EBADF - 유효하지 않은 스트림
    • EINVAL - whence인자 잘못됨
  • fsetpos는 stream의 위치를 pos로 설정한다.
1
int fsetpos (FILE *stream, fpos_t *pos)
  • 이는 whence가 SEEK_SET인 fseek()와 동일하게 동작함.
  • C의 long 타입만으로는 스트림의 위치를 지정하기에 충분하지 않으므로 어떤 플랫폼에서는 이 함수가 스트림 위치를 특정한 값으로 설정할 수 있는 유일한 방법임.
1
2
3
4
5
void rewind (FILE *stream)

// ==

fseek(stream, 0, SEEK_SET);
  • 스트림을 시작 위치로 되돌리며 fseek을 위와 같이 사용하는 것과 동일함.
  • 하지만 fseek()와는 달리 rewind()는 오류 지시자를 초기화 한다.
  • rewind는 반환값이 없어서 에러 조건을 직접적으로 파악할 수가 없음.
1
2
3
4
5
6
// 이런식으로 직접 확인을 해야함.

errno = 0;
rewind(stream);
if (errno)
	//error

3.9.1 현재 스트림 위치 알아내기

  • lseek()와는 다르게 fseek()는 갱신된 위치를 반환하지 않음.
  • 따라서 위치를 파악하기 위한 용도로 분리된 인터페이스를 제공함.
  • ftell 은 현재 스트림 위치를 반환한다.
1
long ftell(FILE *stream);
  • 표준 입출력에서는 fgetpos도 제공을 한다.
1
int fgetpos (FILE *stream, fpos_t *pos)
  • 성공하면 0을 반환하고 현재 스트림 위치를 pos에 기록함.
  • 실패하면 -1을 반환하고 errno를 설정
  • fsetpos()와 마찬가지로 fgetpos()는 복잡한 파일 위치 타입을 사용하는 비-유닉스 플랫폼을 위해 제공한다.

3.10 스트림 비우기

  • 표준 입출력 라이브러리는 사용자 버퍼를 커널로 비워서 스트림에 쓴 모든 데이터가 write()을 통해 실제로 디스크에 기록되도록 만드는 인터페이스를 제공함.
1
int fflush (FILE *stream);
  • stream에 있는 쓰지 않은 데이터를 커널로 비운다.
  • stream이 NULL이면 프로세스의 열려있는 모든 입력 스트림이 비워짐.
  • 성공하면 0 반환, 실패하면 EOF반환하고 errno를 설정
  • fflush()와 버퍼
    • 여기서 설명하는 모든 함수 호출은 커널이 유지하는 버퍼가 아니라 C 라이브러리가 관리하는 버퍼 를 의미한다. 이는 커널 영역이 아니라 사용자 영역에 위치함. → 시스템 콜을 사용하지 않고 사용자 코드를 실행함으로써 성능개선
    • fflush()는 단지 사용자 버퍼에 있는 데이터를 커널 버퍼로 쓰기만 함. → 이는 사용자 버퍼를 사용하지 않고 write()을 직접 사용하는 효과와 동일
    • 즉, 데이터를 매체에 물리적으로 기록한다는 보장이 없다.
    • 데이터가 매체에 즉각 기록되어야 하는 경우에는 fflush()를 호출한 다음 바로 fsync()를 호출한다. → 사용자 버퍼를 커널에 쓰고 fsync()를 통해 커널 버퍼를 디스크에 기록하도록 보장한다.

3.11 에러와 EOF

  • fread()와 같은 몇몇 표준 입출력 인터페이스는 에러와 EOF를 구분하는 방법을 제공하지 않는 등 이슈가 있다.

  • ferror()는 스트림에 에러 지시자가 설정되었는지 검사한다.

1
int ferror(FILE *stream)
  • 에러 지시자는 에러 조건에 따라 표준 입출력 인터페이스에서 설정한다.
  • 해당 스트림에 에러 지시자가 설정되어 있을 경우 0이 아닌 값을 반환, 그렇지 않은 경우 0 반환

  • feof()는 해당 스트림에 EOF 지시자가 설정되어 있는지 검사한다.
1
int feof (FILE *stream)
  • EOF 지시자는 파일 끝에 도달하면 표준 입출력 인터페이스에서 설정한다.

  • clearerr() 함수는 스트림에서 에러 지시자와 EOF 지시자를 초기화한다.

1
void clearerr (FILE *stream);
  • 반환값이 없고 항상 성공하기 때문에 stream 인자값이 정상인지 확인할 수 있는 방법이 없다.
  • 이를 호출하고 나면 다시 복구할 방법이 없으므로 에러 지시자EOF 지시자를 먼저 검사한 후에 호출해야함

3.12 파일 디스크립터 얻어오기

  • 스트림에서 파일 디스크립터를 구해야 하는 경우가 있다.
    • 대응하는 표준 입출력 함수가 없을 때
1
int fileno (FILE *stream)
  • fileno를 통해서 fd를 구할 수 있다.
  • 성공하면 stream과 관련된 fd를 반환하고, 실패하면 -1을 반환.
    • 주어진 스트림이 유효하지 않은 경우 errno를 EBADF로 설정

표준 입출력 함수와 시스템 콜 사이에서 사용자 버퍼링과 관련된 충돌이 발생하지 않도록 주의해야 함 fd를 사용하기전에 스트림을 비우는 것은 좋은 습관. 어쨌든 두가지를 섞어 쓰는 것은 좋지 않다.

3.13 버퍼링 제어하기

  • 표준 입출력은 세 가지 유형의 사용자 버퍼링을 구현하고, 버퍼의 유형과 크기를 다룰 수 있는 인터페이스를 제공한다.
  • 각각의 사용자 버퍼링 타입은 저마다의 목적이 있으며 상황에 맞게 사용할 때 가장 이상적임

버퍼 미사용

  • 사용자 버퍼를 사용하지 않는다.
  • 즉, 커널로 바로 데이터를 보낸다. 표준 에러를 제외하고는 거의 사용되지 않음

행 버퍼

  • 행 단위로 버퍼링을 수행한다.
  • 즉, 개행문자가 나타나면 버퍼의 내용을 커널로 보난다.
  • 화면 출력 메시지는 개행문자로 구분되기 때문에 행 버퍼는 화면 출력을 위한 스트림일 경우 유용함.
  • 표준 출력처럼 터미널에 연결된 스트림에서 기본적으로 사용

블록 버퍼

  • 고정된 바이트 개수로 표현되는 블록 단위로 버퍼링을 수행한다.
  • 기본적으로 파일과 관련된 모든 스트림은 블록 버퍼를 사용한다.
  • 표준 입출력에서는 블록 버퍼링을 Full 버퍼링이라고 한다.

  • 표준 입출력은 버퍼링 방식을 제어할 수 있는 인터페이스를 제공한다.
1
int setvbuf (FILE *stream, char *buf, int mode, size_t size);
  • mode
    • _IONBF - 버퍼 미사용
    • _IOLBF - 행 버퍼
    • _IOFBF - 블록 버퍼
  • buf 와 size를 무시하는 _IONBF를 제외하고 나머지는 size 바이트 크기의 버퍼를 가리키는 buf를 주어진 stream을 위한 버퍼로 사용한다.
    • buf가 NULL이라면 glibc 가 자동적으로 지정된 크기만큼 메모리를 할당한다.
  • 스트림을 연 다음 다른 연산을 수행하기 전에 호출해야함.
  • 제공된 버퍼는 스트림이 닫힐 때까지 반드시 존재해야 한다.
    • 흔히 스트림을 닫기 전에 끝나는 스코프 내부의 자동 변수로 버퍼를 선언하는 실수를 함.
    • 특히 main()에서 지역변수로 버퍼를 만든 다음에 스트림을 명시적으로 닫지 않는 경우를 주의해야함.
      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      int main(void){
        char buf[BUFSIZ];
      
        // stdout을 bufsiz 크기에 맞춰 블록 버퍼로 설정한다.
        setvbuf(stdout, buf, _IOFBF, BUFSIZ);
      
        return 0;
        // buf는 스코프를 벗어나고 해제된다. 하지만 stdout을 닫지 않았음
      }
      
    • 스코프를 벗어나기 전에 스트림을 명시적으로 닫아주거나, buf를 전역 변수로 설정함으로써 방지할 수 있음.

표준 에러를 제외하고 터미널은 행 버퍼링으로 동작, 파일은 블록 버퍼링을 사용하는 것이 맞다. 블록 버퍼링에서 버퍼의 기본 크기는 BUFSIZ이며 일반적인 블록 크기의 정수배인 최적의 값이다.

따라서 개발자들은 일반적으로 스트림을 다룰 때 버퍼링에 대해 고민할 필요가 없다.

3.14 스레드 세이프

스레드 : 개별 프로세스 내에 존재하는 여러 개의 실행 단위. 멀티 스레드 프로세스 : 주소 공간을 공유하는 여러 개의 프로세스

스레드 세이프(Thread-safe) 멀티 스레드 프로그래밍에서 일반적으로 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에 문제가 없음을 뜻함

→ 멀티스레드 환경에서 동작해도 원래 의도한 대로 동작하는 것을 스레드 세이프 하다고 할 수 있음

  • Thread Safe 하지 않은 코드 예시

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    int num;
    boolean is_even;
    
    int inc(int n)
    {
    	num += n;
    	if ((num%2) == 0)
    		is_even = true;
    	else
    		is_evne = false;
    	return num;
    }
    
    • num이라는 변수에 숫자를 더해서 짝수이면 is_even = true, 홀수이면 false로 설정
    • 싱글 스레드 환경에서는 문제없는 코드
    1
    
    a = int(1);
    
    • 위의 라인을 멀티스레드에서 수행했을 경우 의도와 맞지 않는 결과가 발생할 수 있음
  • 멀티코어 시스템에서는 둘 이상의 스레드가 같은 프로세스에서 동시에 실행될 수가 있다.
    • 스레드에서 데이터에 접근할 때 동기화에 주의하지 않거나, 스레드 로컬(스레드 감금) 로 만들지 않으면 스레드가 공유 데이터를 덮어써버릴 수 있다.
  • 스레드를 지원하는 OS는 락 메커니즘을 지원한다. 표준 입출력은 이런 메커니즘을 활용해서 단일 프로세스 내의 여러 스레드가 동시에 표준 입출력을 호출할 수 있도록 한다. 심지어는 같은 스트림에 대해서도 가능하다.
  • 하지만 이것만으로는 부족함
    1. 여러 함수 호출을 그룹으로 묶어 통째로 락을 걸면 크리티컬 섹션이 하나의 입출력 연산에서 여러 입출력 연산으로 확장됨
      • 크리티컬 섹션 : 임계 구역. 다른 스레드의 간섭 없이 실행할 수 있는 코드
    2. 효율성을 높이기 위해 락을 완전히 없애고 싶은 경우
      • 락을 없애면 온갖 문제가 난무하지만 어떤 프로그램은 모든 입출력을 싱글 스레드에 위임하여 스레드를 가두어서 스레드 세이프를 구현하기도 함. → 락에 의한 오버헤드가 없다.
  • 스레드는 입출력 요청에 앞서 락을 획득하고 고유 스레드가 되어야 함
    • 표준 입출력 함수는 본질적으로 스레드 세이프를 보장한다는 것
    • 단일 함수 호출 관점에서 보면 표준 입출력 연산은 Atomic 하다.

3.14.1 수동으로 파일 락 걸기

  • stream의 락이 해제될 때까지 기다린 후에 락 카운터를 올리고 락을 얻은 다음, 스레드가 stream을 소유하도록 만든 후에 반환한다.
1
void flockfile (FILE *stream);
  • funlockfile 함수는 stream과 연관된 락 카운터를 하나 줄인다.
1
void funlockfile(FILE *stream);
  • 락 카운터가 0이 되면 현재 스레드는 stream의 소유권을 포기해서 다른 스레드가 락을 얻을 수 있도록 한다.
  • 여러번 중첩 호출이 가능함.

  • ftrylockfile() 함수는 flockfile()의 논블록 버전이다.
1
int ftrylockfile (FILE *stream)
  • stream이 락이 걸려있다면 ftrylockfile()은 아무것도 하지 않고 즉시 0이 아닌 값을 반환함.
  • 논블록이 아니라면, 락이 걸린 상태에서는 블록되어서 계속 기다려야함.
  • 만약 stream이 락이 걸린 상태가 아니라면 락을 걸고 락 카운터를 하나 올린 다음 그 stream을 소유하도록 만들고 0을 반환한다.
1
2
3
4
5
6
7
flockfile (stream);

fputs("a", stream);
fputs("b", stream);
fputs("c", stream);

funlockfile(stream);
  • 기록 중 다른 스레드가 중간에 끼어들지 못하게 하려면 락을 이용해야함.
  • 설계 자체에서 동일 스트림을 대상으로 입출력하지 않도록 해야한다.
  • 만약 그렇게 할 수 없다면 flockfile() 같은 함수를 이용해서 크리티컬 섹션을 확장해야 함.

3.14.2 락을 사용하지 않는 스트림 연산

  • 상세하고 정밀한 락 제어를 통해 가능한 한 락 오버헤드를 최소화해서 성능을 향상시킬 수 있기때문에 스트림에 대해 수동으로 락을 설정함.
  • 앞서 다루었던 표준 입출력 함수들은 내부적으로 락을 사용한다.
  • 수동으로 락을 걸면 각 표준 입출력 함수들의 내부적인 락을 사용하지 않아도 된다.
  • _unlocked postfix가 붙은 함수들을 이용하면 락 오버헤드를 최소화할 수 있다.

3.15 표준 입출력 비평

  • 몇몇 전문가는 표준 입출력의 결함을 지적함
    • fgets()는 충분한 기능을 제공하지 못한다
      • wild character 같은 경우 eof 로 읽을 수도 있음
    • gets()는 표준에서 제거되기도 함..
      1
      
      경고: the gets function is dangerous and should not be used.
      

      이 문제는 gets함수가 strName[20]의 크기를 모르면서 개행을 찾거나 EOF를 만날 때까지 계속 읽기 때문에 주어진 버퍼의 크기를 넘을 수 있습니다. 다르게 설명하자면 C언어는 경계 검사를 수행하지 않아 gets함수가 접근 권한이 없는 주소에 도달 할 때까지 읽기를 계속합니다. 접근 권한이 없는 주소에 도달하는 이런 행위가 아마도 Linux에서는 시스템을 이상 종료 시킬 수 있는 오류 일 것입니다. 그래서 많은 바이러스가 이러한 문제점을 이용합니다.

  • 가장 큰 불만은 이중 복사로 인한 성능 문제이다.
    • 데이터를 읽을 때 표준 입출력의 read() 시스템 콜을 사용하면 데이터는 커널에서 표준 입출력의 버퍼로 복사된다.
    • fgetc()같은 표준 입출력을 통해서 읽기를 요청하면 그 데이터는 표준 입출력 버퍼에서 인자로 제공된 버퍼로 또 복사된다.
    • 쓰기 요청도 마찬가지
  • 읽기 요청은 표준 입출력 버퍼를 가리키는 포인터를 반환하는 대체 구현으로 이중 복사 문제를 피할 수 있음
  • 쓰기 요청도 포인터 기록을 통해서 피할 수 있음

3.16 맺음말

  • 표준 입출력은 표준 C 라이브러리의 일부로 제공되는 사용자 버퍼링 라이브러리이다.
  • 아래의 가정을 만족할 때 표준 입출력과 사용자 버퍼링은 의미가 있다.
    1. 많은 시스템 콜이 의심되는 경우 수 많은 호출을 합쳐서 줄이는 방법으로 오버헤드를 줄이고 싶다.
    2. 성능이 중요하며 모든 입출력은 정렬된 블록 경계에 맞춰 블록 크기 단위로 일어나도록 확실하게 보장해야 한다.
    3. 접근 패턴이 문자나 행 기반이며 낯선 시스템 콜에 의지하지 않고 손쉽게 데이터에 접근할 수 있는 인터페이스가 필요하다.
    4. 저수준 리눅스 시스템 콜보다는 고수준의 인터페이스를 선호한다.
This post is licensed under CC BY 4.0 by the author.