반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.5의 내용을 읽고 요약한 글입니다. 

 

6.5 mmap: 메모리 읽기와 쓰기 방식으로 파일 처리

코드에서 메모리를 읽기 쓰는 것은 아래와 같이 배열을 정의하고 값을 할당하기만 하면 됨

int a[100];
a[10] = 2;

하지만 파일을 읽는 경우는 상대적으로 더 복잡

char buf[1024];

int fd = open("/filepath/abc.txt");
read(fd, buf, 1024);
// buf 등을 이용한 작업
  • 운영체제에 파일에 접근할 수 있는 파일 서술자를 요청 후 파일의 정보를 읽을 수 있음

파일을 사용하는 것이 메모리를 사용하는 것보다 복잡한 이유

  1. 디스크에서 특정 주소를 지정(addressing)하는 방법과 메모리에서 특정 주소를 지정하는 방법이 다름
  2. CPU와 외부 장치 간 속도에 차이가 있음

파일에 대해 메모리처럼 직접 디스크의 파일을 읽고 쓸 수는 없을까?

6.5.1 파일과 가상 메모리

  • 사용자 상태에서 메모리는 하나의 연속된 공간이고, 디스크에 저장된 파일도 하나의 연속된 공간
  • 두 공간을 연관 짓는 방법은? → 가상 메모리
  • 가상 메모리의 목적은 모든 프로세스가 각자 독점적으로 메모리를 소유하고 있다고 생각하게 하는 것
  • 프로세스가 만나는 주소 공간은 가상이기 때문에 ‘수작을 부려’작업할 수 있는 공간이기도 함
  • 파일은 개념적으로 연속된 디스크 공간에 저장되어 있다고 생각할 수 있으므로 이 공간을 프로세스 주소 공간에 직접 mapping할 수 있음
    • 길이가 200바이트인 파일을 프로세스 주소 공간에 매핑한 결과로 파일이 600~799 주소 범위에 위치하고 있다고 가정하면, 이 주소 공간에서 바이트 단위로 파일을 처리할 수 있음

6.5.2 마술사 운영 체제

  • 600~799 범위의 주소 공간을 읽을 때, 대응하는 파일이 아직 메모리에 적재되지 않아 페이지 누락(page fault) 인터럽트가 발생할 수 있음
  • 이후 CPU가 운영 체제의 인터럽트 처리함수를 실행하며, 실제 디스크 입출력 요청이 시작
  • 파일을 메모리로 읽고 가상 메모리와 실제 메모리의 연결이 수립되면 프로그램에서 메모리를 읽고 쓰듯이 직접 디스크의 내용을 사용할 수 있음
  • mmap을 사용하더라도 실제로 디스크를 읽고 써야 하기는 하지만 이 과정은 운영체제가 진행함
  • 또한 가상 메모리를 경유하기 때문에 고수준 계층에 있는 사용자는 신경쓸 필요 없음

6.5.3 mmap 대 전통적인 read/write 함수

  • read/write 함수 같은 입출력 함수는 저수준 계층에 시스템 호출을 사용함 → 파일을 읽고 쓸 때 데이터를 커널 상태 ↔ 사용자 상태로 복사해야 해서 큰 부담을 수반
  • mmap은 디스크의 파일을 읽고 쓸 때는 시스템 호출과 데이터 복사가 주는 부담이 없음. 하지만 커널은 프로세스 주소 공간관 파일의 mapping 관계를 유지하기 위해 특정 데이터 구조를 사용해야 하며 이 역시 성능에 부담을 가져옴
  • 이외에 page fault 문제가 있어 인터럽트가 발생하면 이에 상응하는 인터럽트 처리 함수가 실제로 파일을 메모리에 적재해야 함

⇒ mmap 과 read/write 함수의 성능 비교를 할 때 실제 구체적인 상황에서 각각의 방식에 대한 부담을 비교하여 선택해야 함

6.5.4 큰 파일 처리

  • 큰 파일이란 물리 메모리 용량을 초과할 정도의 크기를 가진 파일을 의미
  • 전통적인 read/write 함수를 사용하면 파일을 조금씩 나누어 메모리에 적재해야 하며, 파일의 일부분에 대한 처리가 끝나면 다시 다음 부분에 대한 처리를 하는 방식으로 너무 많은 메모리를 요청하면 OOM 문제가 발생할 수 있음
  • mmap을 사용하면 가상 메모리의 도움하에 프로세스 주소 공간이 충분하다면 큰 파일 전체를 프로세스 주소 공간에 직접 mapping할 수 있으며 해당 파일의 크기가 실제 물리 메모리의 크기보다 크더라도 아무 문제 없음
  • mmap을 호출할 때 매개변수가 MAP_SHARED 라면 사상된 영역의 변경 내용은 디스크의 파일에 직접 기록됨. 시스템은 작업중인 파일의 크기가 물리 메모리의 크기보다 큰지 전혀 관심 없음
  • 매개변수가 MAP_PRIVATE 인 경우, 시스템이 실제로 메모리를 할당한다는 의미로 물리 메모리의 크기에 교환 영역(swap area)의 크기를 더한 크기가 기준이 됨

6.5.5 동적 링크 라이브러리와 공유 메모리

  • 동적 링크 라이브러리의 경우 이를 차조하는 프로그램이 아무리 많더라도 실행 파일에는 라이브러리의 코드와 데이터가 포함되지 않음
  • 라이브러리를 참조하는 모든 프로그램이 메모리에 적자되더라도 동일한 동적 라이브러리를 공유하므로 디스크와 메모리의 공간을 절약할 수 있음

mmap으로 해당 라이브러리를 사용하는 모든 프로세스의 주소 공간에 직접 mapping할 수 있는데 여러 프로세스는 모두 해당 라이브러리가 자신의 주소 공간에 적재되었다고 생각하지만, 실제 물리 메모리에서 이 라이브러리가 차지하는 공간은 한개 크기일 뿐임.

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.4의 내용을 읽고 요약한 글입니다. 

 

6.4.1 파일 서술자

  • 모든 입출력 작업은 파일 읽기와 쓰기로 구현할 수 있음

read 함수를 이용해 파일 내용을 읽을 때 아래와 같이 사용하는데 어디에서 데이터를 읽는가?

read(buffer);
  • 유닉스/리눅스 세계에서 파일을 사용하려면 번호를 필요로하는데 이를 파일 서술자(file descriptor)라고 함. 즉 파일 서술자는 숫자에 불과
  • 프로그래머는 파일 서술자라는 숫자를 통해 입출력을 처리할 수 있음
  • 파일이 디스크에 저장되어있는지, 디스크는 어느 위치에 저장되어 있는지 등의 정보를 운영체제가 대신 처리하므로 프로세스는 이를 신경쓸 필요없음
char buffer[LEN];
int fd = open(file_name); // 파일 서술자 얻기

read(fd, buffer);

6.4.2 다중 입출력을 어떻게 효율적으로 처리하는 것일까?

  • 높은 동시성이란 서버가 동시에 많은 사용자 요청을 처리할 수 있음을 의미
  • 웹 서버에서 3way handshake에 성공하면 accept 함수를 호출하여 연결을 얻을 수 있고, 추가로 파일 서술자도 얻을 수 있음. 파일 서술자로 사용자와 통신을 진행
// accept 함수로 사용자의 파일 서술자 획득
int conn_fd = accept(...);
  • 서버의 처리 작동 방식은 일반적으로 먼저 사용자 요청 데이터를 읽고, 이에 따라 특정한 처리를 실행
if(read(conn_fd, buff) > 0)
{
	do_something(buff);
}
  • read 함수는 일반적으로 블로킹 입출력인데 높은 동시성을 위해서는 파일 서술자 수천수만 개를 처리해야 함
  • 다중 스레드를 생각할 수 있지만 이 방법은 스레드 수가 너무 많아질 수 있고 스레드의 스케쥴링과 전환에 너무 많은 부담이 가해지므로 최적의 방법이 아닐 수 있음

6.4.3 상대방인 아닌 내가 전화하게 만들기

  • 여러개의 파일 서술자에 대해 읽고 쓸 수 있는지 매번 체크하는 것이 아니라 관심 대상인 파일 서술자를 커널에 알려주고, 커널에 대신 감시하다가 읽고 쓸 수 있는 파일 서술자가 있을 때 알려주면 처리하겠다고 이야기 하는 것 ⇒ 입출력 다중화(input/output multiplexing) 기술

6.4.4 입출력 다중화

  • 다중화(multiplexing)라는 용어는 사실 통신 분야에서 많이 사용되는데 통신 선로를 최대한 활용하기 위해 하나의 채널에서 여러 신호를 전송할 수 있어야 함 → 여러 신호를 하나로 합칠 필요가 있음. 여러 신호를 하나로 합치는 장치를 다중화기(multiplexer)라고 함
  • 이 신호를 수신하는 쪽에서는 합쳐진 신호를 다시 원래의 여로 신호로 복원해야 하는데 이 장치를 역다중화기(demultiplexer)라고 함

참고: https://velog.io/@seokjun0915/데이터통신-Multiplexing-1

 

입출력 다중화는 아래 과정을 의미

  1. 파일 서술자를 획득. 서술자 종류는 상관없음
  2. 특정 함수를 호출하여 커널에 다음과 같이 알림. ‘이 함수를 먼저 반환하는 대신, 이 파일 서술자를 감시하다 읽거나 쓸 수 있는 파일 서수자가 나타날 때 반환해주세요’
  3. 해당 함수가 반환되면 읽고 쓸 수 있는 조건이 준비된 파일 서술자를 획득할 수 있으며 이를 통해 사응하는 처리를 할 수 있음

이 기술로 여러 입출력을 한꺼번에 처리할 수 있음. 리눅스에서는 입출력 다중화 기술을 사용하는 방법에 3가지가 있음

 

6.4.5 삼총사: select, poll, epoll

  • 본질적으로 select, poll, epoll은 모두 동기 입출력 다중화 기술
  • 이런 함수가 호출될 때 감시해야 하는 파일 서술자에서 읽기/쓰기 가능 같은 관심 대상 이벤트가 나타나지 않으면 호출된 스레드가 블로킹되어 일시 중지되고, 파일 서술자가 해당 이벤트를 생성할 때까지 함수는 반환되지 않음

select

  • 감시할 수 있는 파일 서술자 묶음에 제한이 있으며 일반적으로 1024개를 넘을 수 없음
  • select가 호출될 때 대응하는 프로세스 또는 스레드는 감시 대상인 파일의 대기열에 배치되므로 select 호출로 브로킹되며 일시 중지 됨
  • 파일 서술자 중 하나라도 읽기 가능 또는 쓰기 가능 이벤트가 나타나면 해당 프로세스 또는 스레드가 다시 깨어남

→ 문제는 프로세스가 깨어났을 때 프로그래머는 어떤 파일 서술자가 읽고 쓸 수 있는지 알 수 없어, 준비완료 상태의 팡리 서술자를 알기 위해 처음부터 끝까지 다시 확인해야 함. 대량의 파일 서술자를 감시할 때 효율이 매우 떨어지는 근본적인 원인

poll

  • select와 매우 유사하지만 최적화된 점은 감시 가능한 파일 서술자 수가 1024개 이상이라는 것
  • 하지만 select와 마찬가지로 파일 서술자 수가 늘어날수록 성능이 저하되는 문제가 있음

epoll

  • 커널에 필요한 데이터 구조를 생성하며 준비 완료된 파일 서술자 목록을 가짐
  • 감시되고 있는 파일 서술자에서 관심 이벤트가 발생하면 해당 프로세스를 깨우면서 준비 완료된 파일 서술자가 준비 완료 목록에 추가 됨
  • 프로세스와 스레드에서 모든 파일 서술자를 처음부터 끝까지 확인할 필요 없이 준비완료된 파일 서술자를 직접 획득할 수 있는데 이는 매우 효율적
반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.3의 내용을 읽고 요약한 글입니다. 

6.3.1 메모리 관점에서 입출력

  • 단순히 메모리의 복사(copy)일 뿐, 그 이상도 이하도 아님
  • 그럼 파일 내용을 읽는 것을 예로 들면, 데이터는 잔치에서 프로세스 주소 공간으로 어떻게 복사될까?

6.3.2 read 함수는 어떻게 파일을 읽을까?

  • 단일 코어 CPU 시스템에 프로세스 A와 프로세스 B 두개가 있고, 프로세스 A가 현재 실행중이라고 가정
1. (속에) put (sth in/into sth); (끼우다) insert; (채우다) pack, stuff
2. (손에) get, obtain, come by
3. (추가하다) add, include

 

  • 프로세스 A에는 파일을 읽는 코드가 있고, 먼저 데이터를 저장하는 버퍼를 정의 후 다음과 같이 read 계열의 함수를 호출
char buffer[LEN];
read(buffer);
  • 저수준 계층에서 시스템 호출을 이용하여 운영체제에 파일 읽기 요청을 보냄
  • 요청은 커널에서 디스크가 이해할 수 있는 명령어로 변환되어 디스크로 전송
  • CPU가 명령어를 실행하는 속도에 비해 디스크 입출력은 매우 느려 운영체제는 귀중한 계산 리소스를 불필요한 대기에 낭비할 수 없음
  • 운영체제는 현재 프로세스(프로세스 A)의 실행을 일시 중지하고 입출력 블로킹 대기열에 넣음

  • 운영체제는 이미 디스크에 입출력 요청을 보낸 상태이며 데이터를 특정 메모리 영역으로 복사하는 작업 시작

 

프로세스 B는 준비 완료 대기열에 들어감

 

  • CPU 코어수에 비해 프로세스의 수가 훨씬 많음. 그래서 이미 다시 실행될 준비가 된 프로세스가 있더라도 바로 CPU가 할당되지 못할 수 있기 때문에 준비 완료 대기열에 들어감

프로세스 A가 블로킹 입출력 요청을 시작해서 일시 중지되더라도 CPU는 쉬지 않고 준비 완료 대기열에서 실행할 수 있는 프로세스를 찾아 실행

  • 프로세스 B는 CPU가 실행하고 있으며 디스크는 프로세스 A의 메모리 공간에 데이터를 쓰고 있음

 

  • 이후 데이터가 프로세스 A의 메모리에 복사하는 작업이 완료되면, 디스크는 CPU에 인터럽트 신호를 보냄
  • CPU는 인터럽트 신호를 받은 후 처리가 중단되었던 함수로 점프, 디스크의 입출력 처리가 완료되었음을 알게 됨
  • 운영체제는 프로세스 A를 입출력 블로킹 대기열에서 꺼내 다시 준비 완료 대기열에 넣음

  • 운영체제는 CPU를 프로세스 A에 할당할지, B에 할당할 지 결정해야 함 → 프로세스 B에 할당된 CPU 시간이 아직 남아 계속 B를 실행하는 것으로 가정
  • 프로세스 A는 계속 대기하고, 일정 시간 후 타이머 인터럽트 신호가 발생하면 운영체제는 프로세스 B를 준비 완료 대기열로 넣음과 동시에 프로세스 A를 준비 완료 대기열에서 꺼내 CPU를 할당

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.2의 내용을 읽고 요약한 글입니다. 

  • 최신 컴퓨터 시스템의 경우, 사실 디스크가 입출력을 처리할 때 CPU개입이 필요하지 않음. 이유는? 아래의 장치 제어기, 직접 메모리 접근, 인터럽트의 관계를 이해해야 함
  • 디스크 입출력 요청을 처리하는 동안 운영 체제는 CPU가 다른 작업을 수행하도록 스케쥴링함

상황

  • CPU가 스레드 1을 실행하기 시작하고 일정 시간 지난 후 파일 읽기와 같은 디스크 관련 입출력을 요청한다고 가정
  • 디스크 입출력은 CPU 속도에 비해 매우 느리기 때문에 입출력 요청의 처리가 완료되기 전까지 스레드 1은 앞으로 나아갈 수 없음
  • 쓰레드 1의 실행은 일시 중지 되고 CPU가 준비 완료 상태인 다른 스레드(스레드 2)에 할당됨
  • 스레드 2가 실행되고, 스레드 1의 디스크 입출력 요청 처리가 완료되면 CPU는 스레드 1을 이어서 실행

6.2.1 장치 제어기

  • 디스크와 같은 입출력 장치는 대체로 기계 부분과 전자 부분으로 나눌 수 있음

기계 부분

출처: https://babytiger.netlify.app/posts/hdd/

  • 플래터(Platter): 실질적으로 데이터가 저장되는 곳, 동그란 원판 모양
  • 스핀들(Spindle): 플래터를 회전시키는 구성요소
  • 헤드(R/W Head): 플래터 위에 미세하게 떠있어 데이터를 읽고 씀
  • Actuator Arm:헤드를 원하는 위치로 이동시킴

입출력 요청이 왔을 때 헤드를 통해 데이터를 읽고 쓰는데 데이터가 헤드가 위치한 트랙에 없을 가능성이 있음

  • 트랙: 플래터를 동심원으로 나누었을 때 그중 하나의 원
  • 이때 헤드가 특정 트랙으로 이동해야 하는데 이 과정을 탐색(seek)이라고 하며 매우 많은 시간이 소모
 

전자 부분

  • 전자 부품으로 구성되어 장치 제어기(device controller)라고 함
  • 자체적인 프로세서와 펌웨어, 버퍼, 레지스터를 갖추고 있어 CPU가 직접 도와주지 않는 상황에서도 복잡한 작업을 할 수 있음
  • 장치드라이버(device driver): 운영체제에 속한 코드인데 반해, 장치 제어기는 장치 드라이버에서 명령을 받아 외부 장치를 제어하는 하드웨어

⇒ 장치 제어기는 운영 체제에 해당하는 장치 드라이버와 외부 장치를 연결하는 다리에 해당

 

6.2.2 CPU가 직접 데이터를 복사해야 할까?

  • 디스크에서 자체 버퍼로 데이터를 읽었다면, 이후 CPU는 직접 데이터 전송 명령어를 실행하여 장치 제어기 버퍼에 있는 데이터를 따로 메모리로 복사해야 할까? 그렇지 않음

  • CPU 개입이 없는 상황에서 직접 장치와 메모리 사이에 데이터를 전송할 수 있는 작동방식을 직접 메모리 접근(direct memory access, DMA)라고 부름

 

6.2.3 직접 메모리 접근

  • CPU가 관여하지 않고, 메모리와 외부 장치 사이에서 데이터를 이동시키는 역할을 하는 것이 직접 메모리 접근
  • CPU가 직접 데이터를 복사하지 않지만, 반드시 어떻게 데이터를 복사할지 알려주는 명령어를 DMA에 전달해야 함
  • DMA는 bus arbitration, 즉 버스 사용권한을 요청한 후 장치를 작동시킴
  • 디스크에서 데이터를 읽을 때 장치 제어기의 버퍼에서 데이터를 읽으면 DMA가 지정된 메모리 주소에 데이터를 쓰는 방식으로 데이터 복사가 완료됨
  • DMA가 데이터 전송을 완료하면 인터럽트 작동 방식을 사용하여 CPU에 알려줌

 

6.2.4 전 과정 정리

스레드 1, 2가 있다고 가정

  • CPU로 실행되는 스레드 1이 시스템 호출로 입출력 요청을 시작하면, 운영 체제는 스레드 1의 실행을 일시 중지하고 CPU를 스레드 2에 할당 후 실행됨
  • 디스크가 동작하여 데이터 준비가 되면, DMA 작동 방식이 직접 장치와 메모리 사이에서 데이터를 전송
  • 데이터 전송이 완료되면 인터럽트 작동 방식을 이용하여 CPU에 알리고 CPU는 스레드 2를 중지 후 스레드 1이 다시 실행

⇒ 핵심은 디스크가 입출력 요청을 처리할 때 CPU가 그 자리에서 기다리지 않고 운영 체제의 스케쥴링에 따라 다른 스레드를 실행한다는 것

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.1의 내용을 읽고 요약한 글입니다. 

 

6.1.5 배달 음식 주문과 중단 처리

  • 배달 음식을 주문 후 즐겁게 게임을 한다고 가정
  • 게임 중 배달 음식이 도착하면 게임을 일시 중지한 후 배달 음식을 받고 자리로 돌아와 게임을 이어할 수 있음 → 인터럽트 처리 과정

 

컴퓨터 시스템에서 기초적인 인터럽트 처리 구조

 
  • CPU가 특정 프로세스(프로세스 A)의 기계 명령어를 실행할 때 새로운 이벤트가 발생
    • 네트워크 카드에 새로운 데이터가 들어오면 외부 장치가 인터럽트 신호를 보내고
    • CPU는 실행 중인 현재 작업의 우선순위가 인터럽트 요청보다 높은지 판단
    • 인터럽트의 우선순위가 더 높다면 현재 작업 실행으 일시 중지하고 인터럽트를 처리하고 다시 현재 작업으로 돌아옴
  • 프로그램은 계속 끊임없이 실행되는 것이 아니라 언제든지 장치에 의해 실행이 중단될 수 있음
  • 하지만 이 과정은 프로그래머에게 드러나지 않으며 중단 없이 실행되고 있는 것처럼 느끼게 만듦

 

6.1.6. 인터럽트 구동식 입출력

인터럽트 발생 시 CPU가 실행하는 명령어 흐름

프로그램 A의 기계 명령어 n 실행
프로그램 A의 기계 명령어 n + 1 실행
프로그램 A의 기계 명령어 n + 2 실행
프로그램 A의 기계 명령어 n + 3 실행
인터럽트 신호 감지
프로그램 A의 실행 상태 저장
인터럽트 처리 기계 명령어 m 실행
인터럽트 처리 기계 명령어 m + 1 실행
인터럽트 처리 기계 명령어 m + 2 실행
인터럽트 처리 기계 명령어 m + 3 실행
프로그램 A의 실행 상태 복원
프로그램 A의 기계 명령어 n + 4 실행
프로그램 A의 기계 명령어 n + 5 실행
프로그램 A의 기계 명령어 n + 6 실행
프로그램 A의 기계 명령어 n + 7 실행
  • 폴링 방식보다 효율적으로 시간을 낭비하지 않음
    • 실제는 약간의 시간을 낭비하는데 주로 프로그램 A의 실행 상태를 저장하고 복원하는데 사용

프로그램 A의 관점에서 CPU가 실행하는 명령어 흐름은 아래와 같음

프로그램 A의 기계 명령어 n 실행
프로그램 A의 기계 명령어 n + 1 실행
프로그램 A의 기계 명령어 n + 2 실행
프로그램 A의 기계 명령어 n + 3 실행
프로그램 A의 기계 명령어 n + 4 실행
프로그램 A의 기계 명령어 n + 5 실행
프로그램 A의 기계 명령어 n + 6 실행
프로그램 A의 기계 명령어 n + 7 실행
  • CPU는 마치 중단된 적이 없는 것처럼 자신의 명령어를 계속 실행 → 프로그램 A의 실행 상태를 저장하고 복원하는 작업이 필요한 이유로 입출력을 비동기로 처리하는 방법을 인터럽트 구동식 입출력이라고 함 (interrupt driven input and output)

6.1.7 CPU는 어떻게 인터럽트 신호를 감지할까?

CPU가 기계 명령어를 실행하는 과정

  • 명령어 인출(instruction fetch)
  • 명령어 해독(instruction decode)
  • 실행(execute)
  • 다시 쓰기(write back)
  • CPU가 하드웨어의 인터럽트 신호를 감지하는 단계

인터럽트 신호가 발생하면 이 이벤트를 처리할지 여부를 반드시 결정해야 함

6.1.8 인터럽트 처리와 함수 호출의 차이

  • 함수를 호출하기 이전에 반환 주소, 일부 범용 레지스터의 값, 매개변수 등 정보 저장이 필요
  • 인터럽트 처리 점프는 서로 다른 두 실행 흐름을 포함하므로 함수 호출에 비해 저장해야 할 정보가 훨씬 많음

6.1.9 중단된 프로그램의 실행 상태 저장과 복원

 

  • 프로그램 A가 실행 중일 때 인터럽트가 발생하면 A의 실행은 중단되고, CPU는 인터럽트 처리 프로그램(interrupt handler) B로 점프
  • CPU가 인터럽트 처리 프로그램 B를 실행할 때 다시 인터럽트가 발생하면 B는 중단되고 CPU는 인터럽트 처리 프로그램 C로 점프
  • CPU가 인터럽트 처리 프로그램 C를 실행할 때 도 다시 인터럽트가 발생하면 C의 실행은 중단되고 CPU는 인터럽트 처리 프로그램 D로 점프
  • D의 실행이 완료되면 프로그램 C, B, A 순서대로 반환됨

상태 저장 순서

  1. 프로그램 A의 상태 저장
  2. 프로그램 B의 상태 저장
  3. 프로그램 C의 상태 저장

상태 복원 순서

  1. 프로그램 C의 상태 복원
  2. 프로그램 B의 상태 복원
  3. 프로그램 A의 상태 복원

상태가 먼저 저장될수록 상태 복원은 더 나중에 됨 → 스택를 사용해 구현하고, 스택에는 다음 기계 명령어 주소와 프로그램의 상태가 저장됨

 

 

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.1의 내용을 읽고 요약한 글입니다. 

CPU는 어떻게 입출력 작업을 처리할까?

  • CPU 내부에 레지스터가 있는 것과 마찬가지로, 장치에도 자체적인 레지스터인 장치 레지스터(device register)가 있음
    1. 데이터를 저장하는 레지스터: 사용자가 키보드의 키를 누르면 그 정보는 이런 레지스터에 저장됨
    2. 제어 정보와 상태 정보를 저장하는 레지스터: 읽고 쓰는 작업을 이용하여 장치를 제어하거나 상태를 볼 수 있음

6.1.1 전문적으로 처리하기: 입출력 기계 명령어

Q. 어떤 장치 레지스터를 읽고 써야 하는지 어떻게 알 수 있을까?

A. 장치마다 고유한 주소가 부여되며, 입출력 명령어에 장치 주소를 지정함

6.1.2 메모리 사상 입출력

메모리 사상 입출력(memory mapping input and output): 주소 공간의 일부분을 장치에 할당하여 메모리를 읽고 쓰는 것처럼 장치를 제어하는 방법

  • LOAD와 STORE 명령어 그 자체만으로는 메모리인지, 장치 레지스터를 읽고 쓰는것인지 구분할 수 없으며 추가 적인 정보가 필요 → 메모리 주소 공간(memory address space)

메모리 주소 공간과 실제 메모리 주소는 서로 다른 개념

  • 기계 명령어 관점에서 CPU에 보이는 것은 주소 공간으로 해당 주소의 데이터가 어디에서 오는지 관심을 가질 필요 없음 → 주소 공간의 일부를 장치에 할당할 수 있음

6.1.3 CPU가 키보드를 읽고 쓰는 것의 본질

키보드의 레지스터가 주소 공간의 0xfe00에 매핑되어있다고 가정하면, CPU가 키보드를 읽는 기계 명령어는 아래와 같음

Load R1 0xFE00
  • 주소 공간 oxfe00값을 CPU 레지스터에 적재하는 이 명령어를 이용하여 CPU가 어떻게 키보드에서 데이터를 가져오는지 확인할 수 있음

하지만 문제는 사용자가 언제 키보드를 누를지 확실하지 않음. 어떻게 데이터를 언제 읽어야 할지 알 수 있을까?

  • 최신 CPU의 클럭 주파수는 2~3GHz. 2GHz라고 가정하면, 하나의 클럭 주기는 1/2,000,000,000 = 0.5ns
  • 하나의 기계 명령어가 하나의 클럭 주기마다 실행된다고 가정했을 때, CPU의 속도에 키보드로 입력하는 글자의 속도를 맞춘다면 1초에 20억개를 입력해야 함 → 불가능

⇒ CPU는 특정한 방법을 사용하여 장치의 작업 상태를 얻어야 하는데 이것이 바로 장치 상태 레지스터의 역할

6.1.4 폴링: 계속 검사하기

키보드에서 누른 키의 데이터를 저장하는 레지스터는 0xfe01, 상태 레지스터는 0xfe00위치에 매핑 되어있다고 가정

START
  Load R1 0xFE00        (1)
  BLZ START             (2)
  LOAD R0 0xFE01        (3)
  BL   OTHER_TASK       (4)

(1) Load R1 0xFE00: 현재 키보드 상태를 읽음

(2) BLZ START: 분기 점프 명령어로, 키보드 상태 레지스터 값이 0(아무도 키보드를 누르지 않은 상태)이면, 시작 위치(START)로 점프하여 키보드의 현재 상태 확인, 1이면 아래로 진행

(3) LOAD R0 OxFE01: 키보드의 데이터는 R0 레지스터에 저장

(4) BL OTHER_TASK: 아무론 조건 없이 다른 작업으로 점프

이 코드를 고급언어로 번역하면 다음과 같음

while (키가 눌리지 않음)
{
     ; // 키가 눌릴 때까지 대기 
}

키보드 데이터 읽기
  • 이런 입출력 구현 방법에 polling이라는 매우 직관적인 이름이 붙어있음
  • 문제는 사용자가 키를 누르지 않을 때 CPU는 항상 불필요하게 순환하며 대기하게 됨
  • 일종의 동기식 설계 방식으로 개선할 수 있는 방법은 비동기로 바꾸는 것
 
 
반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 5.3의 내용을 읽고 요약한 글입니다. 

5.3.1 캐시와 메모리 상호 작용의 기본 단위: 캐시 라인

캐시 라인(Cache line)이란?

  • 프로그램이 어떤 데이터에 접근하면 다음에도 인접한 데이터에 접근할 가능성이 높으므로 데이터가 있는 곳의 ‘묶음’ 데이터를 캐시에 저장하는데 이 ‘묶음’을 캐시 라인이라고 함

캐시와 메모리가 상호 작용하는 기본 단위는 캐시 라인이며, 이 크기는 일반적으로 64 바이트임.

캐시가 적중하지 모샇는 경우 이 묶음 데이터가 캐시에 저장됨.

5.3.2 첫번째 성능 방해자: 캐시 튕김 문제

아래 2가지 코드가 있음

첫번째 코드

atomic<int> a;

void threadf()
{
	for(int i=0;i<500000000;i++)
	{
		++a;
	}
}

void run()
{
	thread t1 = thread(threadf);
	thread t2 = thread(threadf);
	
	t1.join();
	t2.join();
}
  • 2개의 스레드를 시작하는데 각 스레드를 전역변수 a값을 1씩 5억번씩 증가

두번째 코드

atomic<int> a;

void run()
{
	for(int i=0;i<1000000000;i++)
	{
		++a;
	}
}
  • 단일 스레드로 전역변수 a값을 1씩 10억번씩 증가
 

 

어떤 코드의 속도가 더 빠를까?

  • 다중 코어 컴퓨터 기준 첫번째 프로그램의 실행시간이 16초, 두번째 실행시간은 8초에 불과했음
  • 병렬 계산임에도 다중 스레드가 단일 스레드보다 느린 이유는?
  • 리눅스의 perf 도구를 사용하여 두 코드를 분석할 수 있음
  • “perf stat” 명령어는 프로그램 실행 시에 나타나는 각종 주요 정보의 통계를 보여주는데 여러 항목 중 insn per cycle 항목에서 차이를 보임

insn per cycle

  • 하나의 클럭 주기에 CPU가 실행하는 프로그램에서 기계 명령어를 몇개 실행하는지 알려줌
  • 다중 스레드는 0.15, 단일 스레드는 0.6으로 단일 스레드 프로그램에서 하나의 클럭 주기 동안 기계 명령어나 4배나 다 많이 실행되었음. 이유는?

캐시 일관성을 보장하기 위해 두 코어의 캐시에서 전역 변수 a처럼 동일한 변수가 사용될 때는 두 캐시에 모두 저장됨

 

두 스레드는 모두 해당 변수에 1을 더해야 함. 이때 첫번 째 스레드가 아래 그림과 같이 a 변수에 덧셈 연산을 실행하기 시작한다면, 다른 cpu 캐시의 a 변수를 무효화(invalidation) 해야 함 → 캐시 튕김 발생

  1. 캐시와 메모리의 불일치 문제를 방지 하기 위해 메모리의 a 변수 값도 업데이트
  2. 동시에 다른 cpu 캐시에 있는 a 변수 값을 무효화

 

1. 아래 cpu의 캐시가 무효화되어 어쩔 수 없이 메모리에서 직접 a변수 값을 읽어야 함

 

  1. 아래 cpu도 a 변수에 1을 더하고 캐시이 일관성을 보장하기 위해 메모리에 a 변수 값을 업데이트
  2. 위 cpu 캐시의 a 변수 무효화 → 또 다시 캐시 튕김이 발생

 

이와 같이 각 cpu의 캐시가 끊임없이 서로 상대 캐시를 무효화하면서 튕겨냄
⇒ 여러 스레드 사이에 데이터 공유를 피할 수 있다면 가능한 피해야 함을 의미

 

5.3.3 두번째 성능 방해자: 거짓 공유 문제

첫번째 코드

struct data
{
	int a;
	int b;
};

struct data global_data;

void add_a()
{
	for(int i=0;i<50000000;i++)
	{
		++ global_data.a;
	}
}

void add_b()
{
	for(int i=0;i<50000000;i++)
	{
		++ global_data.b;
	}
}

void run()
{
	thread t1 = thread(add_a);
	thread t2 = thread(add_b);
	
	t1.join();
	t2.join();
}
  • 스레드 2개를 시작한 후 구조체의 a 변수와 b 변수를 1씩 5억번 증가시킴

두번째 프로그램

void run()
{
	for(int i=0;i<50000000;i++)
	{
		++global_data.a;
	}
	
	for(int i=0;i<50000000;i++)
	{
		++global_data.b;
	}
}
  • 단일 스레드로 동일하게 a 변수와 b 변수를 1씩 5억 번 증가시킴
  • 첫번째 코드가 두 변수를 공유하지 않고 다중 쓰레드 프로그램이니 더 빠르게 실행될 것이라고 예상할 수 있음 → 사실을 그렇지 않음
  • 사실 두 스레드는 어떤 변수도 공유하지 않지만 이 두 변수는 동일한 캐시 라인(cache line)에 있을 가능성이 매우 높아 캐시 튕김 문제가 발생할 수 있음 ⇒ 거짓 공유(false sharing)이라고 함

개선하는 방법으로 두 변수가 같은 캐시라인에 있지 않도록 하는 것인데 아래처럼 구조체를 구성하면 가능

struct data
{
	int a;
	int arr[16];
	int b;
}
  • 다중 코어 컴퓨터에서 캐시 라인 크기가 64바이트이며, arr[16]을 통해 int 형식의 배열을 채우면 a 변수와 b변수는 다른 캐시라인에 있게 됨.
반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 5.2의 내용을 읽고 요약한 글입니다. 

5.2.1 프로그램 지역성의 원칙

프로그램 지역성의 원칙(locality of reference or principle of locality)

  • 본질은 매우 규칙적으로 메모리에 접근한다는 것으로 크게 2가지 종류가 있음
 

 

(1) 시간적 지역성 (temporal locality)

  • 프로그램이 메모리 조각에 접근하고 나서 이 조각을 여러번 참조하는 경우를 이야기 함
  • 캐시 친화성이 매우 높은데, 데이터가 캐시에 있는한 메모리에 접근하지 않아도 반복적으로 캐시의 적중이 가능하다는 단순한 이유

 

(2) 공간적 지역성(spatial locality)

  • 캐시가 적중하지 않으면 메모리의 데이터를 캐시에 적재해야 함
  • 일반적으로 요청한 메모리의 인접 데이터도 함께 캐시에 저장되므로 프로그램이 인접 데이터에 접근할 때 캐시가 적중하게 됨

 

5.2.2 메모리 풀 사용

  • 메모리를 동적으로 할당받을 때 일반적으로 malloc을 이용
  • 메모리 조각 N개를 할당받을 때 malloc을 사용하면 N개 조각이 힙 영역의 이곳 저곳에 흩어져 공간적 지역성에 좋지 않음
  • 메모리 풀 기술은 커다란 메모리 조각(연속적인 메모리 공간)을 미리 할당받으며 메모리 요청/해제 할 때 더 이상 malloc을 거치지 않아 캐시 친화적

 

5.2.3 struct 구조체 재배치

Linked list에 특정 조건을 만족하는 노드가 있는지 판단하려고 할 때, 구조체는 다음과 같이 정의

#define SIZE 100000

struct List
{
	List* next;
	int arr[SIZE];
	int value;
}

Linked list의 노드에는 필요한 값 value와 다음 노드를 가리키는 next 포인터 외에도 배열 arr이 포함되어있음

bool find(struct List* list, int target)
{
	while(list)
	{
		if(list->value == target)
			return true;
		
		list = list->next;
	}
	
	return false;
}
  • 위 코드에서 빈번하게 사용되는 항목은 next 포인터와 value값이며, 배열 arr은 전혀 사용되지 않음
  • 하지만 next 포인터와 value 값이 배열 arr에 의해 멀리 떨어져 있기 때문에 공간적 지역성이 나빠질 수 있어 아래 처럼 함께 배치 → 캐시 적중률 상승시킬 수 있음. 
#define SIZE 100000

struct List
{
	List* next;
	int value; // next 아래에 value
	int arr[SIZE];
}

 

5.2.4 핫 데이터와 콜드 데이터의 분리

  • 핫데이터 (hot data): 더 빈번하게 사용되는 데이터
  • 콜드 데이터(cold data): 덜 빈번하게 사용되는 데이터

위 코드에서 next, value는 핫데이터, arr는 콜드 데이터라고 할 수 있음

일반적으로 Linked list에 노드가 하나뿐인 경우는 거의 없으며, 노드가 비교적 많을 때에는 캐시해야 할 노드가 비교적 많아짐 → 캐시 용량은 제한적!

Linked list 자체가 차지하는 저장 공간이 클수록 캐시에 저장할 수 있는 노드는 줄어듬. 아래와 같이 배열 arr을 다른 구조체에 넣고 List 구조체 안에 이 구조체를 가리키는 포인터를 추가할 수 있음

#define SIZE 100000

struct List
{
	List* next;
	int value; // next 아래에 value
	strcut Arr* arr;
};

struct Arr
{
	int arr[SIZE];
};
  • 콜드 데이터와 핫 데이터를 분리하면 더 나은 지역성을 얻을 수 있는데 이런 방법을 사용하려면 각 항목하다 접근 빈도를 알고 있어야 함

 

5.2.5 캐시 친화적인 데이터 구조

  • 지역성 원칙 관점에서 배열이 linked list보다 나음
    • C++, std::vector 컨테이너가 std::list 컨테이너 사용보다 나음
  • 실제로 사용할 때는 캐시 친화적 여부를 포함하여 구체적인 상황에 맞추어 선택해야 함
  • 예를 들어, 배열의 공간적 지역성은 linked list보다 낫지만, 노드의 추가/삭제가 빈번하게 발생하는 경우에는 linked list가 배열에 비해 우수함
    • Linked list의 노드 추가/삭제에 대한 시간 복잡도 O(1)
  • 노드의 추가/삭제가 쉬운 장점을 유지하면서 캐시 친화적이고 싶은 linked list를 사용하려면? → 직접 정의한 메모리 풀에서 메모리를 요청하면 됨

이런 최적화를 진행할 때 반드시 분석 도구를 사용하여 캐시의 적중률이 시스템 성능에 병목이 되는지 판단해야 함. 병목이 되지 않으면 굳이 이런 최적화를 할 필요가 없음

 

5.2.6 다차원 배열 순회

  • 배열을 row, column 순서로 순회하면서 값을 모두 더하는데 C언어 역시 row 우선 방식(row major)으로 배열을 저장
    • row major: 메모리에 저장할 때 첫번째 행이 첫번째 열부터 마지막 열까지 저장되고, 이어서 두번째 행이 첫번째 열부터 마지막 열까지 저장되는 것을 반복하는 구조
int matrix_summer(int A[M][N])
{
    int i, j, sum = 0;
	
    for(i=0; i< M;i++)
    {
        for(j=0;j<N;j++)
        {
            sum += A[i][j];
        }
    }
	
    return sum;
}

 

  • 배열에 행 4개와 열 8개가 있다고 가정 (M이 4, N이 8)
  • 캐시가 최대 4개의 int 형식 데이터를 저장할 수 있다고 가정

  • 순회가 시작되면 캐시에 아직 배열에 대한 데이터가 없기 때문에 캐시는 비어있어 배열 A의 첫번째 요소인 A0에 접근하는 시점에 캐시가 적중할 수 없음 → A0를 포함한 요소 4개가 캐시에 저장됨

 

  • 캐시 준비가 완료되어 A1부터 A3에 접근할 때는 모두 캐시가 적중하기 때문에 메모리에 접근할 필요가 없음

 

  • A4에 접근할 때는 캐시 용량의 제한으로 캐시가 적중할 수 없는데 다시 A4개를 포함한 요소 4개가 캐시에 저장되어 이전 데이터와 교체됨

  • 각 행마다 8개 요소들 중 2개를 miss하기 때문에 75% 의 캐시 적중률을 보여줌

 

이번에는 열 우선 방식으로 접근 (column major)

int matrix_summer(int A[M][N])
{
	int i, j, sum = 0;
	
	for(j=0; j< N;j++)
	{
		for(i=0;i<M;i++)
		{
			sum += A[i][j];
		}
	}
	
	return sum;
}

 

첫번째 요소인 A0에 접근하면 아직 캐시가 적중할 수 없기 때문에 아래와 같이 A0부터 A3까지 캐시에 저장됨

그러나 우리 코드에서 다음에 접근할 요소는 A8이며 이 데이터는 여전히 캐시가 적중할 수 없고 캐시에 A8부터 A11을 저장. 이전 캐시는 사용되지 못함

  • 위와 같이 열 우선 방식으로 배열을 순회하면 캐시는 매번 적중에 실패하여 적중률은 0

 

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 5.1의 내용을 읽고 요약한 글입니다. 

5.1.1 CPU와 메모리의 속도 차이

https://softwareg.com.au/blogs/computer-hardware/cpu-bandwidth-vs-memory-bandwidth

  • CPU 속도는 1년에 52%씩 성능이 증가한 반면, 메모리 속도는 1년에 7%씩 증가
  • CPU는 명령어를 실행할 때 어쩔 수 없이 메모리의 처리를 기다려야 함
    • 일반적인 시스템에서 메모리의 속도는 CPU의 100분의 1

 

5.1.2 도서관, 책상, 캐시

  • 도서관에서 책상은 캐시에 비유할 수 있고, 서가는 메모리에 비유 가능
  • CPU와 메모리 사이에 캐시 계층이 추가되어, 최근에 메모리에서 얻는 데이터가 저장됨
  • 캐시가 적중하면 메모리에 접근할 필요가 없어 CPU가 명령어 실행하는 속도를 크게 끌어올릴 수 있음
    • 캐시 적중: CPU가 요청한 데이터가 캐시에 이미 저장되어있어 캐시가 해당 요청을 이행할 수 있을 때 발생

CPU는 메모리와 직접 상호 작용하지 않고 캐시와 상호 작용

 

 

  • 일반적으로 x86 CPU와 메모리 사이에는 세 단계의 캐시가 추가 되어 있음
    • L1, L2, L3
  • L1 캐시 접근 속도: 레지스터 접근 속도에 비해 약간 느리지만 대동 소이하기 때문에 대략 4클럭 주기가 소요됨
  • L2캐시 접근 속도: 대략 10클럭 주기 소요
  • L3캐시 접근 속도 : 대략 50클럭 주기 소요
  • 캐시 단계에 따라 접근 속도는 낮아지지만 용량을 증가

  • L1, L2, L3 캐시, CPU 코어는 레지스터 칩 내에 묶여 패키징되어 있음
  • CPU가 메모리 내에 데이터가 필요하다고 판단하면 우선 L1 캐시에서 데이터를 검색
  • 없다면 L2, L3 를 순차적으로 검색 후 모두 없으면 메모리에 직접 접근하여 캐시에 데이터를 갱신

 

5.1.3 공짜 점심은 없다: 캐시 갱신

불일치 문제

  • 캐시의 데이터는 갱신되었지만 메모리의 데이터는 아직 예전 것이 남아 있어 캐시와 메모리 사이에 불일치가 발생할 수 있음

해결 방법

1. 연속 기입(write-through)

  • 캐시를 갱신할 때 메모리도 함께 갱신하는 방법
  • 캐시를 업데이트하면 어쩔 수 없이 메모리에 접근해야 함 → CPU는 메모리가 갱신될 때까지 대기하고 있어야 함 → 동기식 설계 방법
  • 이 상황을 최적화하는 방법은 동기를 비동기로 바꾸는 것

2. 후기입(write-back)

  • 캐시 용량에 한계가 있어 용량이 부족하면 자주 사용되지 않는 데이터를 제거해야 함
  • 캐시에서 제거된 데이터가 수정된 적이 있다면 이를 메모리에 갱신해야 함
  • 캐시의 갱신과 메모리의 갱신이 분리되므로 비동기임
  • 연속 기입보다 훨씬 복잡하지만 성능은 분명 더 나음

자주 사용할만한 데이터를 캐싱하는데, 삭제할 때만 메모리에 갱신하는 방식이 후기입

 

5.1.4 세상에 공짜 저녁은 없다: 다중 코어 캐시의 일관성

  • 단일 CPU 성능 향상이 어려워 숫자를 늘리게 됨 → 다중 코어 시대 진입
  • CPU가 코어를 여러개 가지면 다른 문제가 발생하는데 두 코어에서는 다른 스레드 2개가 실행됨

Step 1

  • 두 스레드 모두 메모리 내 X변수에 접근해야하고 변수의 초기값이 2라고 가정
  • 코어 2개는 모두 X 변수를 사용해야 한다

캐시 동작 원리에 따르면,

  • 처음으로 X 변수를 읽으면 캐시에 적중할 수 없으므로 X 변수를 메모리에서 읽어야 함
  • 이어서 대응하는 캐시에 갱신되므로, 이제 cpu1 캐시와 cpu2 캐시는 모두 X 변수를 가지고 있고 그 값은 2

 

Step2

  1. C1은 X 변수에 2를 더해야 하는데 캐시 동작 원리에 따라 C1은 캐시에서 X 변수 값을 가져온 후 2를 더해서 다시 캐시에 갱신
  2. 이 시스템이 연속 기입(write-through) 방식이라고 가정하면 메모리도 동기 방식으로 동시에 갱신되고 메모리에 있는 X 변수 값은 4

 

Step3

  1. cpu2 도 X 변수에 덧세 연산을 수행하는데 4를 더한다고 가정
    1. 캐시 동작 원리에 따라 cpu2는 캐시에서 X 변수 값을 가져온 후 4를 더해서 다시 캐시를 갱신
    2. 캐시 값은 6으로 바뀌는데 연속 기입 방식에 따라 메모리를 갱신하면 메모리에 저장된 값도 6이 됨 ⇒ 문제 바생
  • 초기 값이 2인 변수에 2 와 4를 각각 더한 후 결과는 8이 되어야 하지만 X 변수 값은 6이 됨

문제는 메모리의 X 변수에 cpu1, cpu2의 캐시에 복사본 2개를 가지고 있기 때무네 발생하고 C1 캐시가 갱신될 때 C2 캐시는 동기적으로 수정되지 않아 다중 코어에서 캐시 데이터의 불일치 문제가 발생함

⇒ 해결책: 캐시 한개에 갱신된 변수가 다른 CPU 코어의 캐시에도 존재한다면 함께 갱신되어야 함

  • 최신 CPU에는 고전적인 MESI 규칙(MESI protocol)같은 다중 코어 캐시의 일관성을 유지하는 규칙이 있음

 

5.1.5 메모리를 디스크의 캐시로 활용하기

  • 디스크는 탐색(seek) 을 위해 10ms 가량의 시간이 소요됨 (메모리 접근 속도가 10만배 가량 빠름)
    • 물론 모든 디스크 접근에 탐색이 필요하지는 않음

메모리와 디스크의 속도 차이를 해결하기 위해 둘 사이에 직접 캐시를 추가하면 어떨까?

먼저 레지스터가 메모리의 캐시가 될 수 없는 이유는 레지스터 크기가 한정되어있기 때문. 메모리 용량은 사실 꽤 큰편이라(GB 단위) 어려움

  • 최신 운영체제는 분명히 메모리를 디스크의 캐시로 사용
  • 일반적으로 메모리 사용률은 100%에 도달하지 않으며 이 여유 공간을 낭비시키지 않도록 디스크의 캐시로 활용하여 디스크에서 데이터 읽어 오는 일을 최소화 함 → 페이지 캐시의 기본원리

결국 CPU의 내부 케시가 메모리 데이터를 저장하고, 메모리가 디스크 데이터를 저장

  • 최근 서버에서는 메모리가 디스크를 대체하는 것이 대세 → 메모리 가격이 엄청 저렴해졌기 때문
  • AWS에서는 2TB 용량의 메모리를 탑재한 인스턴스도 제공
  • Presto, Flink, Spark 같은 메모리 기반 시스템이 디스크 기반의 경쟁자들을 빠르게 대체하고 있음

 

5.1.6 가상 메모리와 디스크

  • 앞서 모든 프로세스가 표준 크기의 자체적인 주소 공간을 가지고 있다고 여러번 이야기 함
    • 물리 메모리와는 관련이 없어 물리 메모리의 크기를 초과활 수 있음

Q. 시스템에 프로세스 N개가 실제 물리 메모리를 모두 사용하고 있을 때 새로운 프로세스가 생성되어 N+1번째 프로세스도 메모리를 요청한다면 시스템이 어떻게 처리할까?

A. 디스크는 메모리의 창고 역할을 할 수 있어 자주 사용하지 않은 메모리 데이터를 디스크에 기록하고 데이터가 차지하던 물리 메모리 공간을 해제할 수 있음. 그러면 N+1 번째 프로세스가 다시 메모리를 요청할 수 있음

 

5.1.7 CPU는 어떻게 메모리를 읽을까?

  • CPU가 볼 수 있는 것은 모두 가상 메모리 주소로 실제 물리 메모리 주소로 변환되어야 함
  • 변환이 완료되면 캐시를 검색하기 시작하고 캐시에서 찾을 수 없을 때는 메모리에 접근
  • 프로세스의 데이터는 디스크에 임시로 보관되어 있을 수 있는데 이때 메모리에서 데이터를 찾을 수 없을 가능성이 있으며 디스크의 프로세스 데이터를 다시 메모리에 적재한 후 메모리를 읽어야 함
  • 빅 데이터의 시대가 도래해 단일 장치의 디스크만으로 더이상 데이터를 완전히 저장할 수 없다면 대안은?

 

5.1.8 분산 저장 지원

  • 분산 파일 시스템(distributed file system)을 직접 장착(mount)할 수도 있고, 로컬 디스크는 원격의 분산 파일 시스템에서 전송된 파일을 저장 → 로컬 디스크를 원격의 분산 파일 시스템의 캐시로 간주할 수 있음
  • 물론 응답 속도를 높이기 위해 원격 분산 파일 시스템의 데이터를 데이터 흐름(data stream) 형태로 직접 로컬 컴퓨터 시스템의 메모리로 끌어올 수도 있음 (apache kafka)
  • 이 경우 대용량 메시지는 원격 분산 파일 시스템에 저장되어 있지만 실시간으로 해당 데이터는 소비자에게 전달되고 이때 메모리를 원격 분산 파일 시스템의 캐시로 간주
반응형
반응형
 

이 글은  컴퓨터 밑바닥의 비밀 chapter 4.7 의 내용을 읽고 요약한 글입니다. 

4.7.1 복잡함을 단순함으로

  • 마이크로코드의 설계 아이디어: 복잡한 기계 명령어를 CPU 내부에서 비교적 간단한 기계 명령어로 변환하는것으로 컴파일러는 이 프로세스를 알지 못함
  • 마이크로코드에 버그가 있으면 컴파일러는 이를 피하기 위해 할 수 있는 일이 없음

또한 복잡한 기계명령어 하나가 같은 일을 하는 간단한 명령어 여러개보다 느리게 실행된다는 사실을 발견

4.7.2 축소 명령어 집합의 철학

  • 복잡 명령어 집합(Complex Instruction Set Computer, CISC) 에 대한 반성으로 축소 명령어(Reduced instruction Set Computer, RISC) 집합의 철학이 탄생

3가지 측면을 주로 반영

1. 명령어 자체의 복잡성

  • 복잡한 명령어를 제거하고 간단한 명령어 여러개로 대체
  • 마이크로코드 설계가 필요없으며 컴파일러에서 생성된 기계 명령어의 CPU 제어 능력이 크게 향상됨
  • 명령어 집합을 줄인다는 아이디어는 명령어 집합의 명령어 개수를 줄이다는 의미가 아니라, 하나의 명령어당 들여야 하는 연산이 더 간단하다는 의미

2. 컴파일러

  • 컴파일러가 CPU에 대해 더 강력한 제어권을 갖음
  • CPU는 더 많은 세부사항을 컴파일러에 제공함

3. LOAD/STORE 구조

  • 복잡한 명령어 집합에서는 기계 명령어 하나로 메모리에서 데이터를 가져오고(IF), 작업을 수행하고(EX), 해당 데이터를 메모리에 다시 쓰는 (WB) 작업을 모두 할 수 있음
  • 축소 명령어 집합의 명령어는 레지스터 내 데이터만 처리할 수 있으며, 메모리 내 데이터는 직접 처리할 수 없음 

Q. 누가 메모리를 읽고 쓰는 것인가?

  • LOAD와 STORE라는 전용 기계 명령어가 메모리의 읽고 쓰기를 책임짐
  • 다른 명령어는 CPU 내부의 레지스터만 처리

 

4.7.3 CISC, RISC 차이

두 숫자가 각각 메모리 주소 A와 주소 B에 저장된 상태에서 두 숫자를 곱한 값을 먼저 계산한 후 결과를 다시 메모리 주소 A에 기록 한다고 가정

(1) CISC의 경우

  • CISC는 적은 수의 기계 명령어로 가능한 한 많은 작업을 수행하는 것
  • MULT(iplication)이라는 기계 명령어가 있을 수 있으며 명령어를 실행할 때 다음 동작이 수행되어야 함
  1. 메모리 주소 A의 데이터를 읽어 레지스터에 저장
  2. 메모리 주소 B의 데이터를 읽어 레지스터에 저장
  3. ALU가 레지스터 값을 이용하여 곱셈 연산 수행
  4. 곱셈 결과를 다시 메모리 주소 A에 씀
MULT A B

a = a * b; // 고급 언어와 매우 유사. 이런 설계는 고급 언어와 기계 명령어 사이의 차이를 줄임

 

(2) RISC의 경우

  1. 메모리 주소 A의 데이터를 읽어 레지스터에 저장
  2. B의 데이터를 읽어 레지스터에 저장
  3. ALU가 레지스터 값을 이용하여 곱셈 연산 수행
  4. 곱셈 결과를 다시 메모리에 씀
  • RISC에서는 LOAD, PROD, STORE 명령어를 사용하여 작업을 단계별로 완료해야 함
    • LOAD: 메모리에서 레지스터로 데이터를 적재
    • PROD: 두 레지스터에 저장된 숫자의 곱셈 연산을 수행
    • STORE: 레지스터의 데이터를 다시 메모리에 씀

어셈블리어 코드

LOAD RA, A
LOAD RB, B
PROD RA, RB
STORE A, RA
  • 복잡 명령어 집합을 사용하는 프로그램보다 기계 명령어를 더 많이 사용
  • RISC의 의도는 프로그래머가 직접 어셈블리어로 코드를 작성하는 것이 아니라, 이 작업을 컴파일러에게 맡기고 컴파일러가 구체적인 기계 명령어를 자동으로 생성하게 하는 것

4.7.4 명령어 파이프라인

  • 위 어셈블리어 코드는 각 명령어가 매우 간단하여 실행 시간이 모두 거의 동일하여 파이프라인 기술을 통해 실행 효율을 높일 수 있음

4.7.5 천하에 명성을 떨치다

  • 1980년대 중반 RISC 기반의 CPU가 CISC 기반 CPU를 압도
반응형

+ Recent posts