Home [Linux System Programming] Ch02 파일 입출력
Post
Cancel

[Linux System Programming] Ch02 파일 입출력

Ch02 파일 입출력

2. 파일 입출력

리눅스는 많은 인터페이스를 파일로 구현했음. (유닉스 시스템에서는 거의 모든 것을 파일로 표현)

파일 입출력은 단순한 파일 처리를 넘어서 다양한 작업에 밀접하게 관련되어 있음.

파일은 읽거나 쓰기 전에 반드시 열어야 한다.

  • File Table
    • 커널은 파일 테이블이라고 하는 프로세스 별로 열린 파일 목록을 관리 함.
    • File Descripter(fd)
      • 음이 아닌 정수 값으로 인덱싱 되어있음
    • 각 항목은 열린 파일에 대한 정보를 담고 있음
      • inode Pointer
      • Metadata
  • fd
    • 0 : stdin
    • 1 : stdout
    • 2 : stderr
    • 읽고 쓸 수 있는 모든 것은 파일 디스크립터를 통해 접근할 수 있음 Untitled

2.1 파일 열기

  • 파일 접근하는 기본적인 방법
    • read(), write()
    • 하지만 접근하기 전에 open() 이나 creat() 로 열고, 다 쓴 후에는 close()로 닫아야함.

open() 시스템 콜

1
2
int open (const char *name, int flags);
int open (const char *name, int flags, mode_t mode);
  • 경로 이름이 name인 파일을 fd에 맵핑
    • 성공하면 fd return
  • offset 0으로 설정
    • 실패 시 -1 리턴
  • flags
    • O_RDONLY, O_WRONLY, O_RDWR
    • 이외에도 여러가지 플래그들이 있는데, flags 파라미터에 OR연산을 통해 작동 가능
      • O_WRONLYO_TRUNC

새로운 파일의 권한

파일 생성하는게 아니라면 open()의 mode 인자는 무시됨.

하지만 O_CREAT과 같이 생성 시에는 mode가 꼭 필요하다.

creat() 함수

O_WRONLYO_CREATO_TRUNC

읽기 전용, 해당 파일이 없으면 새로 생성, 파일이 존재하고 일반파일이며 flags인자에 쓰기가 가능하다면 파일 길이를 0으로 잘라버림

→ 너무나도 일반적인 flags라서 이를 지원하는 시스템 콜이 바로 create()

1
2
3
4
5
fd = creat(filename, 0644);

==

fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);

2.2 read()로 읽기

1
ssize_t read (int fd, void *buf, size_t len);

호출할 때마다 fd가 참조하는 파일의 현재 오프셋에서 len 바이트만큼 buf로 읽어 들인다.

  • return
    • 성공 시 buf에 쓴 바이트 숫자
    • 실패 시 -1

read()의 여러가지 반환 케이스

1
nr = read (fd, &word, len)
  • nr == len 같은 값 : 정상
  • 0 < nr < len : 읽은 바이트는 word에 저장.
    • 중간에 시그널이 중단 or 읽는 도중 에러 발생 or len 만큼 읽기전에 EOF 발생
    • → 원인 파악 가능 (buf ,len 을 고친다음 호출 수행)
  • nr == 0 : EOF
  • 블록 : 현재 사용가능한 데이터가 없음
  • nr == -1 && EINTR : 바이트 읽기 전에 시그널 도착
  • nr == -1 && EAGAIN : 읽을 데이터가 없어서 블록. 논믈록 모드 일 때만 일어나는 상황
  • nr == -1 && OTHER : 심각한 에러

→ 일반적인 read()는 에러 처리하면서 실제로 모든 len 바이트( 적어도 EOF까지)를 읽는 경우 적합하지 않음

1
2
3
4
5
6
7
8
9
10
11
while (len != 0 && (ret = read(fd, buf, len)) != 0) {
	if (ret == -1){
		if (errno == EINTR)
			continue;
		perror ("read");
		break;
	}

	len -= ret;
	buf += ret;
}

위의 코드는 5가저 조건을 모두 처리한다.

논블록 읽기

  • 때떄로 프로그래머 입장에서 읽을 데이터가 없을 때 read()호출이 블록되지 않기를 바라는 경우가 있음
  • 블록되는 대신 읽을 데이터가 없다는 사실을 알려주기 위해 호출이 즉시 반환되는 편을 선호한다.
  • open() 할 때 플래그를 O_NONBLOCK을 넘겨주었다면 파일 디스크립터를 논블록으로 열게되는데, 이 때 읽을 데이터가 없다면 read() 는 호출이 블록되는 대신 -1을 반환하면서 errno를 EAGAIN으로 설정한다.

Blocking I/O Model

Untitled

  • I/O 작업이 진행되는 동안 유저 프로세스는 자신의 작업을 중단한채 대기해야함. → 리소스 낭비가 심하다.
  • 이를 해결하기 위해 클라이언트 별로 쓰레드를 만들어 연결시켜준다면 클라이언트 수가 늘어날 수록 쓰레드가 너무 많아진다. 이렇게 되면 context switching 횟수가 증가하게 됨. 비효율적

Non Blocking I/O Model

Untitled

  • I/O 작업을 진행하는 동안 유저 프로세스의 작업을 중단시키지 않는다.
  • 함수를 호출하면 진행상황과 상관없이 바로 결과를 반환

read() 크기

  • size_t
    • 바이트 단위로 크기를 측정하기 위해 사용되는 값 저장
    • SIZE_MAX
  • ssize_t
    • 부호가 있는 size_t 이다. (signed)
    • 음수 에러를 포함하기 위해 사용
    • SSIZE_MAX
      • len이 이보다 큰 경우의 read() 호출결과는 정의되어 있지 않음
      • 32bit 기계에서는 0x7ffffffff

2.3 Write()로 쓰기

파일에 데이터를 기록하기 위해 사용하는 가장 기본적이며 일반적인 시스템 콜

1
ssize_t write (int fd, const void *buf, size_t count);

count 바이트만큼 fd가 참조하는 파일의 현재 위치에 시작지점이 buf인 내용을 기록한다.

  • return
    • 성공하면 쓰기에 성공한 바이트 수를 반환
    • 에러가 발생하면 -1을 반환하며 errno를 적절한 값으로 설정
1
2
3
4
5
6
7
/*buf에 들어있는 문자열을 fd가 가리키는 파일에 입력한다 */
const char *buf = "My ship is solid!";
ssize_t nr;

nr = write(fd, buf, strlen (buf));
if (nr == -1)
	/* 에러 */

덧붙이기 모드

  • O_APPEND 옵션으로 open()을 하게되면 현재 파일 오프셋이 아니라 파일 끝에서부터 쓰기 연산이 일어난다.
  • 다중 프로세스가 같은 파일 수정을 진행했을 때 race condition이 발생한다. 이는 명시적인 동기화 과정 없이 동일 파일에 덧붙이는 작업이 불가능함을 의미함.
  • 덧붙이기 모드를 이용하면 프로세스가 여럿 존재할지라도 항상 덧붙이기 작업이 수행됨

write() 동작방식

리눅스 커널은 디스크의 데이터를 캐싱하는데, 이를 페이지 캐시(page cache)라고 하고, 캐시되어 있던 페이지가 다시 디스크로 적용되는 것(동기화 되는 것)을 page writeback이라고 한다. 페이지 캐시의 최대 목적은 디스크 입출력을 최소화 시키는 데 있다.

  • 디스크 접근은 메모리 접근에 비해 상대적으로 많이 느리다. milliseconds vs nanoseconds
    • L1 > L2 > L3 > Memory > Disk
  • Data Locality: 최근에 사용된 데이터는 다시 사용될 가능성이 높다.

Write caching

3가지 동작 예상

  1. write()에 대해서 이미 캐싱해놓은 데이터와는 상관없이 바로 디스크에 데이터를 내려버리는 경우.

    즉, 메모리에 있는 캐시 데이터를 지나치고 바로 디스크로 데이터를 갱신한다.

    이 경우에는 기존에 캐싱되어 있는 페이지 캐시는 invalidate 된다. 만약 read()가 해당 데이터에 대해서 들어오면 디스크로부터 읽어온다.

  2. 메모리에 있는 캐시와 디스크 모두 갱신해준다. 가장 간단한 방법으로 이러한 방식을 write-through cache라고 한다. 캐시부터 디스크까지 모두 write() 연산이 수행된다. 이 경우 캐시와 디스크 모두를 항상 최신 상태로 만들어주기 때문에 캐시를 일관성있게 유지해준다. (cache coherent)
  3. (현재 Linux에서 사용하고 있는 방식) write back 방식은 write() 요청이 들어왔을 때 페이지 캐시에만 우선 갱신하고 backing store에는 바로 갱신하지 않는 방식이다. 이 방식을 채택하면 cache와 원본 데이터가 서로 다르게 되며, 캐시에 있는 데이터가 최신 데이터가 된다. 최신 데이터는 캐싱이 된 이후로 업데이트가 되었다는 의미로 dirty 상태(unsynchronized)가 되며 dirty list에 추가되어 커널에 의해 관리된다. 커널은 주기적으로 dirty list에 등록되어 있는 페이지 캐시를 backing store에 동기화해주는데 이러한 작업을 writeback이라고 한다. writeback 방식은 write-through 방식보다 나은 방법인데, 왜냐하면 최대한 디스크에 쓰는 것을 미루어둠으로써 나중에 대량으로 병합해서 디스크에 쓸 수 있기 때문이다. 단점은 조금 더 복잡하다는 것이다.

출처(https://scslab-intern.gitbooks.io/linux-kernel-hacking/content/chapter16.html)

2.4 동기식 입출력

필요한 경우

  1. 디스크에 순서대로 기록해야만 하는 경우 → 커널의 대기열에서 성능 개선에 적합한 방식으로 쓰기 요청 순서를 변경하기 때문에 문제가 발생할 수 있음

  2. 시스템이 비정상 종료될 경우 → 버퍼에 있는 내용을 디스크에 쓰기 전에 시스템이 종료될 수 있음

  • 분명히 입출력을 동기화 하는 것은 중요한 주제임.
  • 하지만 Write 작업이 지연되는 문제를 너무 확대 해석하면 안됨.
  • 최신 OS라면 버퍼를 통해서 지연된 쓰기 작업 을 구현하고 있다.
  • 그럼에도 시점을 제어하고 싶을 때가 있기 때문에 리눅스 커널에서는 “성능”을 희생하는 대신, 입출력을 동기화하는 몇 가지 옵션을 제공한다.

fsync()와 fdatasync()

  • fsync()를 호출하면 fd에 맵핑된 파일의 모든 변경점을 디스크에 기록한다.
    • 반드시 fd는 쓰기 모드로 열려야함.
  • fdatasync()는 fsync()와 동일한 기능을 하지만, 메타데이터까지 저장하는 fsync()와는 다르게 데이터만 기록한다. 그렇기 때문에 더 빠름.
1
2
3
4
int ret;
ret = fsync(fd);
if (ret == -1);
	/* ERROR */
  • 몇몇 리눅스 배포판에서 fdatasync()는 구현되어있지만, fsync()는 구현되어있지 않을 때도 있다. 이때는 EINVAL를 반환

sync()

  • 모든 버퍼 내용(데이터와 메타데이터 모두) 을 디스크에 강제로 기록해서 동기화함.
  • 최적화는 조금 부족
1
void sync (void);
  • 인자도 없고, 반환 값도 없음
  • → 항상 호출 성공

O_SYNC 플래그

  • open() 호출 시 O_SYNC 플래그를 사용하면 모든 파일 입출력은 동기화 됨.
  • (읽기는 언제나 동기화 됨. 하지만 write은 보통 동기화 되지 않음)
  • write()가 작업 후 반환하기 직전에 fsync()를 매번 호출하는 방식이라고 이해해도 좋음.
    • (실제로 리눅스 커널에서는 좀 더 효율적인 방식으로 구현하고 있지만 의미는 동일)
  • Latency 가 조금씩 늘어남
    • → 입출력 동기화에 들어가는 비용이 매우 크기 때문에, 다른 대안을 모두 적용한 다음 최후의 선택으로 사용해야함
  • 일반적으로 쓰기 작업이 디스크에 바로 기록되어야 하는 애플리케이션에서는 fsync()나 fdatasync()를 사용함. 이들은 호출 횟수가 적어서 O_SYNC보다 비용이 적게 듬.

O_DSYNC와 O_RSYNC

  • O_DSYNC는 메타데이터를 제외한 일반 데이터만 동기화 (fdatasync()와 동일)
  • O_RSYNC는 쓰기뿐만 아니라 읽기까지도 동기화되도록 한다.
    • read() 호출은 특별한 옵션 없이도 항상 동기화 되기 때문에 O_RSYNC 플래그가 특별히 필요하지 않다. 다만 최종적으로 사용자에게 넘겨줄 데이터가 생길 때 까지 반환되지 않음.
    • 리눅스는 O_RSYNC를 O_SYNC와 동일하게 정의한다.
      • 리눅스 구현상 이런 동작을 구현하기가 쉽지 않다고 한다.

2.5 직접 입출력

  • 리눅스 커널은 디바이스와 애플리케이션 사이에 캐시, 버퍼링, 입출력 관리 같은 복잡한 계층을 구현하고 있음
  • 성능이 중요한 애플리케이션에서는 우회해서 직접 입출력을 하고 싶을수도 있다.
    • 일반적으로는 노력에 비해 효과가 낮다.
    • 하지만 DB시스템은 독자적인 캐시를 선호하며 OS의 개입을 최소한으로 줄이기를 원함
  • O_DIRECT
    • open()호출에서 O_DIRECT를 넘기면 커널이 입출력 관리를 최소화하도록 한다.
    • 페이지 캐시를 우회해서 사용자 영역 버퍼에서 직접 디바이스로 입출력 작업을 시작한다.
    • 모든 입출력은 동기식.
    • 입출력 작업이 완료된 후에 호출이 반환 됨

2.6 파일 닫기

1
int close (int fd);
  • fd로 읽고 쓰는 작업을 마치면 close로 파일 맵핑을 끊어야한다.
  • close()를 호출하면 fd에 연관된 파일과의 맵핑을 해제하며 프로세스에서 파일을 떼어낸다.
  • 파일을 닫더라도 파일을 디스크에 강제로 쓰지 않는다는 점을 기억해야한다.
    • 확실히 기록하려면 동기식 입출력 방법 중 하나를 써야함

에러 값

  • 지연된 연산에 의한 에러는 한참 후에도 나타나지 않기 때문에 close()의 반환값을 검사해주는 것이 중요함.
  • EBADF (파일 디스크립터가 유효하지 않음)
  • EIO (저수준의 입출력에러)

2.7 lseek()로 탐색하기

  • 가끔 파일의 특정 위치로 직접 이동해야 할 필요가 있을 떄가 있다.
  • lseek()을 사용하면 fd에 연결된 파일의 오프셋을 특정 값으로 지정할 수 있다.
  • 파일 오프셋 갱신 외에 다른 동작은 하지 않고 어떤 입출력도 발생하지 않음.
1
off_t lseek (int fd, off_t pos, int origin);
  • origin
    • SEEK_CUR
      • fd의 파일 오프셋을 현재 오프셋에서 pos값을 더한 값으로 설정. pos값은 음수, 0, 양수 모두 가능
      • pos가 0이면 현재 파일 오프셋을 반환
    • SEEK_END
      • fd의 파일 오프셋을 현재 오프셋에서 pos값을 더한 값으로 설정. pos값은 음수, 0, 양수 모두 가능
      • pos가 0이면 파일 오프셋을 현재 파일의 끝으로 설정
    • SEEK_SET
      • fd의 파일 오프셋을 pos값으로 설정
      • pos가 0이면 파일 오프셋을 파일의 처음으로 설정
  • 현재 파일 오프셋 찾기
    • lseek()은 갱신된 파일 오프셋을 반환하므로 lseek()에 SEEK_CUR와 0을 pos값으로 넘기면 현재 파일 오프셋을 찾을 수있다.
      1
      
      pos = lseek (fd, 0, SEEK_CUR)
      
  • 파일의 시작 혹은 끝 지점으로 오프셋을 이동하거나, 현재 오프셋을 알아내는데 많이 사용 됨!

파일 끝을 넘어서 탐색하기

  • 파일 끝을 넘어서도록 위치를 지정하는 것은 아무런 일도 발생하지 않음.
  • 이때 read()를 하면 EOF반환
  • 이때 write()를 하면 마지막 오프셋과 새로운 오프셋 사이에 새로운 공간이 만들어지며 0으로 채워짐
  • 0으로 채운 공간을 구멍 (spare file)이라고 하는데, 이 구멍들은 물리적인 디스크 공간을 차지하지 않음

→ 파일시스템에서 모든 파일을 합친 크기가 물리적인 디스크 크기보다 더 클 수 있음

→ 이런 파일이 공간을 상당히 절약하며 효율을 크게 높일 수 있다.

제약사항

  • 파일 오프셋의 최댓값은 off_t의 크기에 제한됨.
    • 커널은 내부적으로 오프셋 값을 C의 long long타입으로 저장
    • 64비트 머신에서는 문제가 되지 않지만, 32비트 머신에서는 EOVERFLOW 에러 발생 가능

2.8 지정한 위치 읽고 쓰기

1
ssize_t pread (int fd, void *buf, size_t count, off_t pos);
  • pread()를 사용하면 fd에서 pos오프셋에 있는 데이터를 buf에 count 바이트만큼 읽는다.
1
ssize_t pwrite (int fd, const void *buf, size_t count, off_t pos);
  • pwrite()를 사용하면 buf에 담긴 데이터를 fd의 pos 오프셋에 count 파이트만큼 쓴다.

  • 둘 모두 작업 후 파일 오프셋을 갱신하지 않는다.
  • 둘 모두 현재 파일의 오프셋을 무시하며 pos로 지정한 오프셋을 사용한다는 점을 제외하고는 read()와 write() 시스템 콜과 거의 유사하게 동작한다.
  • read() 나 write() 호출 전에 lseek()을 호출하는 방식과 유사하지만 3가지 차이점이 존재
    1. 작업 후 파일 오프셋을 원위치로 되돌리거나 임의의 오프셋에 접근해야 하는 경우 쉽게 사용가능
    2. 호출이 완료된 후 파일 포인터를 갱신하지 않음
    3. lseek()를 사용할 때 발생할 수 있는 경쟁 상태를 피할 수 있다.
      • lseek()은 본질적으로 여러 스레드에서 같은 fd를 처리할 경우 안전하지가 않음. race condition 발생 가능.

에러 값

  • 두 함수는 호출이 성공하면 읽거나 쓴 바이트 개수를 반환함.
  • pread() 0반환 : EOF
  • pwrite() 0 반환 : 아무런 데이터도 쓰지 못했음
  • pread()는 read()와 lseek()에서 허용하는 errno 값을 설정
  • pwrite()는 write()와 lseek()에서 허용하는 errno 값을 설정

2.9 파일 잘라내기

파일을 특정 길이만큼 잘라내기 위한 시스템 콜

1
int ftruncate (int fd, off_t len)
1
int truncate (const char *path, off_t len);
  • 두 시스템 콜은 모두 파일을 len 크기만큼 잘라낸다.
  • ftruncate()는 쓰기 모드로 열린 fd에 대해 동작.
  • truncate()는 쓰기 권한이 있는 파일 경로에 대해서 동작
  • 성공
    • 둘 다 0을 반환
  • 에러
    • -1 반환, errno를 적절한 값으로 설정함.
  • 호출이 성공하면 파일의 길이는 len이 된다. len과 자르기 전의 파일 크기 사이에 존재하던 데이터는 없어지고, read()를 통해 이 영역에 접근할 수 없게 됨.

2.10 다중 입출력

  • 논블록 입출력이 효과적이지 않은 두가지 이유
    1. 프로세스는 계속 열린 fd 중 하나가 입출력을 준비할 때까지 기다리면서 어떤 임의의 순서대로 입출력을 요청해야 한다.
    2. 프로세스를 재워 다른 작업을 처리하게 하고 fd가 입출력을 수행할 준비가 되면 깨우는 편이 더 효과적일 수 있음. 논블록 입출력으로 이것을 해결할 수 있지만 프로세스가 계속 깨워져있어야 한다는 단점이 존재한다.
  • 다중 입출력은 애플리케이션이 여러개의 fd를 동시에 블록하고 그중 하나라도 블록되지 않고 읽고 쓸 준비가 되면 알려주는 기능을 제공
    1. 다중 입출력: fd 중 하나가 입출력이 가능할 때 알려준다.
    2. 준비가 됐나? 준비된 fd가 없다면 하나 이상의 fd가 준비될 때까지 잠든다.
    3. 깨어나기. 어떤 fd가 준비됐나?
    4. 블록하지 않고 모든 fd가 입출력을 준비하도록 관리한다.
    5. 1로 돌아가서 다시 시작한다.

select()

1
2
3
4
5
6
7
8
9
10
int select (int n,
						fd_set *readfds,
						fd_set *writefds,
						fd_set *exceptfds,
						struct *timeval *timeout);

struct timeval {
	long tv_sec;
	long tv_usec;
}
  • select() 호출은 fd가 입출력을 수행할 준비가 되거나 옵션으로 정해진 시간이 경과할 때까지만 블록된다.
  • 파라미터
    • n
      • fd 집합에서 가장 큰 fd 숫자에 1을 더한 값
      • 즉, fd에서 가장 큰 값이 무엇인지 알아내서 1 더해야 함.
    • readfds
      • 블록되지 않고 read()작업이 가능한지를 파악하기 위해 감시
    • writefds
      • 블록되지 않고 write()작업이 가능한지를 파악하기 위해 감시
    • exceptfds
      • 예외가 발생했거나 대역을 넘어서는 데이터 (이는 소켓에만 적용) 가 존재하는지 감시
    • → 어떤 집합이 NULL이면 해당 이벤트 감시하지 않음
    • timeout
      • NULL이 아니면 입출력이 준비된 fd가 없을 경우에도 tv_sec, tv_usec 이후에 반환됨.
      • 두 값이 모두 0이면 호출은 즉시 반환됨.
  • 호출이 성공하면
    • 각 집합은 요청받은 입출력 유형을 대상으로 입출력이 준비된 fd만 포함하도록 변경된다.
    • ex) 7과 9인 두개의 fd가 readfds에 들어있다고 한다면 호출이 반환될 때 7이 집합에 남아있고 9가 남아있지 않다면, 7은 블록없이 읽기 가능! 9는 아마도 읽기 요청이 블록될 것임
  • select()에서 사용하는 fd집합은 직접 조작하지 않고 매크로를 사용해서 관리함
1
2
3
4
FD_CLR(int fd, fd_set *set);
FD_ISSET(int fd, fd_set *set);
FD_SET(int fd, fd_set *set);
FD_ZERO(fd_set *set);
  • FD_ZERO 는 지정된 집합내의 모든 fd를 제거함. 항상 select() 호출 전에 사용해야함
  • FD_SET은 주어진 집합에 fd를 추가함
  • FD_CLR은 주어진 집합에서 fd를 하나 제거함
    • 제대로 설계된 코드라면 FD_CLR을 사용할 일이 절대 없음!
  • FD_ISSET은 fd가 주어진 집합에 존재하는지 검사
    • 집합에 들어있다면 0이 아닌 정수 반환. 들어있지 않다면 0반환

반환값과 에러코드

  • 호출 성공
    • 전체 세 가지 집합 중에서 입출력이 준비된 fd개수를 반환함.
    • timeout을 초과하면 반환값이 0이 될 수 있다.
  • 에러 발생
    • -1 반환, errno 설정

select() 예제

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

#define TIMEOUT 5
#define BUF_LEN 1024

int main() {
    struct timeval tv;
    fd_set readfds;
    int ret;

    // 표준 입력에서 입력을 기다리기 위한 준비를 합니다.
    FD_ZERO(&readfds);
    FD_SET(STDIN_FILENO, &readfds);

    // select가 5초 동안 기다리도록 timeval 구조체를 설정합니다.
    tv.tv_sec = TIMEOUT;
    tv.tv_usec = 0;

    // select() 시스템콜을 이용해 입력을 기다립니다.
    ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);

    if (ret == -1) {
        perror("select");
        return 1;
    }
    else if (!ret){
        printf("%d seconds elapsed.\n", TIMEOUT);
        return 0;
    }

    // select() 시스템콜이 양수를 반환했다면 '블록(block)'없이 즉시 읽기가 가능합니다.
    if (FD_ISSET(STDIN_FILENO, &readfds)) {
        char buf[BUF_LEN + 1];
        int len;

        // '블록(block)'없이 읽기가 가능합니다.
        len = read(STDIN_FILENO, buf, BUF_LEN);
        if (len == -1) return 1;
        if (len) {
            buf[len] = '\0';
            printf("read: %s\n", buf);
        }

        return 0;
    }
}

select()로 구현하는 이식 가능한 sleep

1
2
3
4
tv.tv_sec = 0;
tv.tv_usec = 500;

select (0, NULL, NULL, NULL, &tv);
  • 역사적으로 select()는 1초 미만의 짧은 시간 동안 프로세스를 재울 수 있는 더 나은 방법을 제공해왔음
  • 최신 리눅스는 아주 짧은 시간 잠들기 인터페이스를 지원하고 있음

pselect()

1
2
3
4
5
6
7
8
9
10
11
int pselect (int n,
						fd_set *readfds,
						fd_set *writefds,
						fd_set *exceptfds,
						struct *timespec *timeout,
						const sigset_t *sigmask);

struct timespec {
	long tv_sec;
	long tv_nsec;
}
  • select()와의 차이점
    1. pselect()는 timeout 인자로 timeval 구조체 대신 timespec 구조체를 사용. 이는 초, 나노 초 조합을 사용하므로 이론적으로 더 짧은 시간 동안 잠들 수 있다. 하지만 실제로는 둘 다 마이크로 초도 확실히 지원하지 못함..
    2. pselect()는 timeout 인자를 변경하지 않기 때문에 잇달은 호출 과정에서 timeout 인자를 계속 초기화해야 할 필요가 없다.
    3. select() 시스템 콜은 sigmask 인자를 받지 않는다. 이 인자는 NULL로 설정하면 pselect()는 select()와 동일하게 동작한다.
  • pselect()가 추가된 이유
    • fd와 시그널을 기다리는 사이에 발생할 수 있는 race condition 을 해결하기 위한 sigmask 인자를 추가하기 위함이다.
    • 블록할 시그널 목록을 인자로 받아서 select() 도중에 시그널이 도착하는 경우에도 이를 처리함.
    • sigmask가 가리키는 신호마스크가 자동으로 설정되어 차단되고, pselect() 호출이 반환될 때는 신호마스크가 복원되어 실행하게 된다.

poll()

  • select()의 몇 가지 결점을 보완함.
    • select()는 ‘읽기, 쓰기 예외’ 3가지를 독립적으로 설정하고 매개변수로 넘겨줘야했다. 하지만 poll은 구조체 배열을 사용함으로써 설계상으로도 훨씬 더 좋아졌음.
  • 그럼에도 불구하고 여전히 습관이나 이식성의 이유료 select()를 더 많이 사용함
1
2
3
4
5
6
7
int poll (struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
	int fd;
	short events;
	short revents;
};
  • fds가 가리키는 단일 pollfd 구조체 배열을 nfds 개수만큼 사용함.
  • events 필드는 fd에서 감시할 이벤트의 비트마스크 를 의미
    • POLLIN - 읽을 데이터가 존재한다. 즉, 읽기가 블록(blokc)되지 않는다.
    • POLLRDNORM - 일반 데이터를 읽을 수 있다.
    • POLLRDBAND - 우선권이 있는 데이터를 읽을 수 있다.
    • POLLPRI - 시급히 읽을 데이터가 존재한다.
    • POLLOUT - 쓰기가 블록(block)되지 않는다.
    • POLLWRNORM - 일반 데이터 쓰기가 블록(block)되지 않는다.
    • POLLWRBAND - 우선권이 있는 데이터 쓰기가 블록(block)되지 않는다.
    • POLLMSG - SIGPOLL 메시지가 사용 가능하다.
  • events를 설정하면 등록한 이벤트 중 발생한 이벤트가 revents필드에 설정된다.
  • revents 필드는 등록한 이벤트 중 발생된 이벤트 정보를 커널이 설정해줌.
  • revents 필드에는 다음 이벤트가 설정될 수 있다.

    • POLLER - 주어진 파일 디스크립터에 에러가 있다.
    • POLLHUP - 주어진 파일 디스크립터에서 이벤트가 지체되고 있다.
    • POLLNVAL - 주어진 파일 디스크립터가 유효하지 않다.
  • 예제1
    • fd의 읽기와 쓰기를 감시하려면 events 를 POLLINPOLLOUT으로 설정
    • 호출이 반환되면 pollfd 구조체 배열에서 원하는 fd가 들어있는 항목을 찾아 revents에 해당 플래그가 켜져있는지 확인한다.
    • POLLIN 이 설정되어 있다면 읽기는 블록되지 않음.
    • POLLOUT이 설정되어 있다면 쓰기는 블록되지 않는다.
  • 예제2

    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
    33
    34
    35
    36
    
    #include <stdio.h>
    #include <unistd.h>
    #include <poll.h>
    
    #define TIMEOUT 5
    
    int main() {
        struct pollfd fds[2];
        int ret;
    
        // 표준 입력에 대한 이벤트를 감시하기 위한 준비를 한다
        fds[0].fd = STDIN_FILENO;
        fds[0].events = POLLIN;
    
        // 표준 출력에 쓰기가 가능한지 감시하기 위한 준비를 한다.
        fds[1].fd = STDOUT_FILENO;
        fds[1].events = POLLOUT;
    
        // 위에서 pollfd 구조체 설정을 모두 마쳤으니 poll() 시스템콜을 작동시킨다.
        ret = poll(fds, 2, TIMEOUT * 1000);
    
        if (ret == -1) {
            perror("poll");
            return 1;
        }
    
        if (!ret) {//타임아웃
            printf("%d seconds elapsed.\n", TIMEOUT);
            return 0;
        }
    
        if (fds[0].revents & POLLIN) printf("stdin is readable\n");
        if (fds[1].revents & POLLOUT) printf("stdout is writeable\n");
    
        return 0;
    }
    

ppoll()

  • ppoll()은 리눅스에서만 사용가능한 인터페이스
  • pselect() 처럼 timeout 인자는 나노 초 단위로 지정 가능하며 블록할 시그널 집합은 sigmask 인자로 제공

poll()과 select() 비교

  • 비슷한 작업을 하지만 poll은 select 보다 훨씬 유용함!
    1. poll은 가장 높은 파일 fd값에다가 1을 더해서 인자로 전달할 필요 없음
    2. select에서 값이 900인 fd를 감시하게되면 매번 fd 집합에서 900번째 비트까지 일일히 검사해야함
    3. select 의 fd 집합은 크기가 정해져있어서 트레이드 오프가 발생함. poll은 딱 맞는 크기의 fd 집합을 사용함
    4. select 는 fd 집합을 반환하는 시점에서 재구성되므로 매번 fd 집합을 초기화해야함. poll은 event(입력), revent(출력)이 분리되어있다.
    5. select 의 timeout 인자는 반환하게 되면 미정의 상태가 됨.

2.11 커널 들여다보기

가상 파일 시스템(VFS)

  • 사용 중인 파일시스템이 무엇인지 몰라도 파일시스템 데이터를 처리하고 파일 시스템 함수를 호출할 수 있도록 하는 추상화 메커니즘
  • 추상화를 위해서 리눅스에서 모든 파일시스템의 기초가 되는 공통 파일 모델을 제공함.
  • 일반적인 시스템 콜(read, write) 등은 커널이 지원하는 어떠한 파일시스템이나 매체에서도 파일을 다룰 수 있다

페이지 캐시

  • 디스크 파일 시스템에서 최근에 접근한 데이터를 저장하는 메모리 저장소
  • 메모리에 쓰기를 요청한 데이터를 저장하면 동일한 데이터에 대한 요청이 연이어 발생할 경우 커널은 반복적인 Disk 접근을 피해서 메모리에서 바로 처리할 수 있다.
  • Temporal Locality 라는 개념을 활용함
    • 특정 시점에서 리소스에 접근하면 오래 지나지 않은 장래에 다시 또 접근할 가능성이 높다는 이론
  • Sequential Locality
    • 데이터가 순차적으로 참조됨을 뜻함
    • 이를 활용하기 위해 페이지 캐시 미리 읽기를 구현하고 있음
  • 커널이 파일시스템 데이터를 탐색하는 첫번째 장소가 페이지 캐시 이다.
  • 동적으로 페이지 캐시 크기 변경 가능
    • 메모리가 가득차게되면 페이지 캐시 중에서 가장 적게 사용한 페이지를 삭제해서 메모리를 확보한다.
    • 이런 작업은 자동적으로 매끄럽게 일어남.
  • 디스크 스왑과 캐시 삭제 간의 균형을 맞추는 데는 휴리스틱 기법을 사용함

페이지 쓰기 저장

  • 커널은 버퍼를 통해 쓰기 작업을 지연시킨다.
  • 쓰기 요청을 하면 버퍼로 데이터를 복사한 다음 버퍼에 변경 표시를 하여 디스크에 있는 복사본보다 메모리에 있는 복사본이 새롭다고 알려준다. 그러면 쓰기 요청은 바로 반환된다.
  • 최종적으로 버퍼에 있는 내용이 디스크로 반영되어 디스크와 메모리에 있는 데이터가 동기화가 되어야하는데 이를 쓰기 저장이라고 한다. 두 가지 상황에서 발생함
    • 여유 메모리가 설정된 경계 값 이하로 줄어들면 변경된 버퍼를 디스크에 기록한 다음, 버퍼를 삭제해서 메모리 공간을 확보한다.
    • 설정된 값보다 오랫동안 유지된 버퍼는 디스크에 기록된다. 이는 변경된 버퍼가 무한정 메모리에만 남아있는 상황을 방지한다.
  • 쓰기 저장은 Flusher 스레드 라고 하는 커널 스레드 무리에서 수행함

2.12 마무리

  • 가능한 모든 것을 파일로 표현하는 리눅스 같은 시스템에서는 어떻게 파일을 열고, 읽고, 쓰고, 닫는지 이해하는 것이 매우 중요하다. 이 모든 연산은 유닉스의 고전이며 여러 표준에 기술되어 있다.
This post is licensed under CC BY 4.0 by the author.