반응형

이 글은  컴퓨터 밑바닥의 비밀 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를 압도
반응형
반응형

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

 

4.6.1 프로그래머의 눈에 보이는 CPU

  • CPU는 단순하게 메모리에서 명령어를 읽어 실행하기만 하면 됨
  • 어떤 프로그램이든 컴파일러에 의해 기계 명령어로 변환될 뿐

4.6.2 CPU의 능력 범위: 명령어 집합

  • 명령어 집합은 우리에게 CPU가 할 수 있는 일을 알려줌
  • 서로 다른 형태의 CPU는 다른 유형의 명령어 집합을 가지고 있음

4.6.3 추상화: 적을수록 좋다

  • 1970년까지 컴파일러가 성숙하지 못해 신뢰도 낮았음
  • 많은 프로그램이 어셈블리어로 작성되었는데 명령어 집합이 더욱더 풍부해야되고 명령어 자체 기능도 더 강력해야 한다고 여겼음

4.6.4 코드도 저장 공간을 차지한다

  • 실행파일은 기게 명령어와 데이터를 모두 포함하며 디스크 저장 공간을 차지하고, 실행시에는 메모리에 적재 되어 메모리 저장 공간을 차지
  • 옛날 컴퓨터는 메모리의 크기가 몇 KB로 작았는데 더 많은 프로그램을 적재하려면 기계 명령어는 더 세밀하게 설계해서 프로그램이 차지하는 저장공간을 줄였어야 함

아래 3가지 요구 사항을 만족해야 함

  1. 하나의 기계 명령어로 더 많은 작업을 완료할 수 있도록
  2. 기계 명령어 길이 가변적 (간단한 명령어는 짧고, 복잡한 명령어는 길게)
  3. 밀도를 높여 공간을 절약하기ㅇ 위해 고도로 인코딩

4.6.5 필연적인 복잡 명령어 집합의 탄생

당시 산업계의 요구사항을 충분히 만족시켰지만 새로운 문제가 발생

  • CPU명령어 집합은 모두 hardwired (직접 연결) 방식으로 효율적이지만 유연성이 떨어짐

하드웨어를 변경하는 것은 번거롭지만 소프트웨어를 통해 대체 가능 → microcode 등장

단일 복잡 명령어 → 마이크로코드 ROM → 간단한 명령어들

  • 더 많은 명령어를 추가할 때 주요 작업은 마이크로 코드 수정에 집중되며 하드웨어 수정은 거의 필요하지 않기에 CPU 설계 복잡도를 낮출 수 있음

4.6.6 마이크로코드 설계의 문제점

  • 버그 수정이 훨씬 어렵고 마이크로코드 설계가 트랜지스터를 매우 많이 소모
반응형
반응형

 

 

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

 

4.2.1 컴퓨터의 CPU 사용률은 얼마인가?

  • 게임, 영상 편집, 이미지처리 같은 일을 하지 않는 한 대부분 컴퓨터의 CPU 사용률은 낮음(7~8%)
  • Windows 경우 아래와 같이 Task Manager 의 Details 탭의 CPU 컬럼에서 확인할 수 있다.
  • 근데 System Idle Process 항목은 뭐길래 96%를 차지하고 있는걸까?

 

4.2.2 프로세스 관리와 스케쥴링

  • OS는 프로세스에 우선순위를 할당하고 이에 따라 스케쥴러가 스케쥴링할 수 있도록 대기열에 프로세스를 넣음

4.2.3 대기열 상태 확인: 더 나은 설계

  • 준비 완료 대기열이 비어있다면 OS가 스케쥴링해야 할 프로세스가 없고 CPU는 유휴상태임을 의미
if(queue.empty())
{
    do_something();
}
  • 위처럼 코드를 작성할 수도 있지만 커널은 if 같은 예외처리 구문으로 가득하기 때문에 코드가 번잡해보일 수 있음 → 항상 실행할 수 있는 프로세스를 찾을 수 있도록 하면 됨
    • linked list에서 sentinel 노드를 사용하는 이유
  • NULL판단 로직이 제거되어 코드 오류가능성이 줄고 구조가 깔끔하게 됨

*System Idle Process가 유휴 작업이라는 프로세스 by 커널 설계자. 항상 준비완료 상태이며 우선순위가 가장 낮음

  • 유휴 프로세스는 무엇을 하나? 이를 설명하기 위해서 CPU를 이야기 해야한다.

 

4.2.4 모든 것은 CPU로 돌아온다

  • halt 명령어를 통해 CPU 내부의 일부 모듈을 절전 상태로 전환하여 전력 소비를 크게 줄임
    • 커널 상태에서 CPU로만 실행할 수 있음

참고) 일시중지(suspend) vs halt
- sleep같은 함수는 일시 중지로 해당 함수를 호출한 프로세스만 일시 중지
- CPU가 halt 명령어를 실행하는 것은 시스템 내 더 이상 실행할 준비가 완료된 프로세스가 없다는 의미

4.2.5 유휴 프로세스와 CPU의 저전력 상태

  • halt 명령어를 지속적으로 실행해주는 순환이 있어 CPU는 저전력 상태로 진입하기 시작
while (1)
{
    while (!need_resched())
    {
        cpuidle_idle_call(); // halt 명령어 실행
    }
}

4.2.6 무한 순환 탈출: 인터럽트

위 코드의 이상한점들

  1. 위 코드를 보면 무한 while loop 구조 내부에 break/return 문이 없는데 어떻게 빠져나올까?
  2. 무한 loop이 있어서 프로그램이 CPU 독점하는 것으로 보이지 않음
  • OS는 일정 시간마다 타이머 인터럽트를 생성하고, CPU는 인터럽트 신호를 감지해 인터럽트 처리 프로그램을 실행함
  • 인터럽트 처리함수에는 프로세스의 상태를 파악해 준비완료된 프로세스가 있으면 기존 프로세스를 중단하고 준비완료된 프로세스를 실행
반응형
반응형

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

 

트랜지스터 (https://news.samsungdisplay.com/23538/)

트랜지스터의 기능은 단자 한쪽에 전류를 흘리면 나머지 단자 두 개에 전류가 흐르게/흐르지 못하게 할 수 있는데 본질은 스위치와 동일하다.

트랜지스터를 이용해 아래의 3가지 회로를 만들 수 있다.

  • 논리곱 게이트(AND 게이트): 스위치기 두개가 동시에 켜질 때만 전류가 흐르고 등이 켜짐
  • 논리합 게이트(OR 게이트): 두 스위치 중 하나라도 켜져 있으면 전류가 흐를 수 있으며 등이 켜짐
  • 논리부정 게이트(NOT 게이트): 스위치를 닫으면 전류가 흘러 등이 켜지지만, 스위치를 열면 전류가 흐르지 않고 등이 꺼짐

 

연산 능력은 어디에서 나올까?

CPU의 가장 중요한 능력인 연산중 덧셈을 예로 들면, 0과 1만 아는 2진법을 사용

  • 0 + 0 의 결과(result)는 0이며, 자리올림수(carry)도 0
  • 0 + 1 의 결과는 1, 자리올림수 0
  • 1 + 0 의 결과는 1, 자리올림수 0
  • 1 + 1 의 결과는 0, 자리올림수 1

먼저 자리올림수는 두 입력 값 두개가 모두 1일 때만 1 ⇒ AND 게이트를 사용하면 된다.

그렇다면 결과는? 두 입력값이 다를 때만 1이고 같으면 0이다. → 배타적 논리합 게이트 (XOR) 을 사용하는데 이도 AND, OR, NOT 게이트로 구성할 수 있다.

⇒ 즉 2진법 덧셈을 AND 게이트 1개, XOR 게이트 1개를 조합해 구현할 수 있다.

배타적 논리 합 게이트 예시

 

신기한 기억 능력

정보를 기억하는 회로란?

  • S=1, R=0인 경우 Q는 항상 1을 출력
  • S=0, R=1인 경우 Q는 항상 0을 출력하는데 이때 회로에 해당 값이 저장되었다고 할 수 있다.

위는 정보를 저장하기 위해 S, R 단자 값을 동시에 설정해야 하는데 아래처럼 하면 실제로 저장하는데 필요한 입력은 D의 비트 하나이다.

* EN 은 저장 여부를 선택하는데 사용되는 단자

  • D단자가 0이면 전체 회로가 저장하는 값은 0
  • D단자가 1이면 전체 회로가 저장하는 값은 1

 

레지스터와 메모리의 탄생

여러비트를 저장하기 위해서는 위의 회로를 복제하여 붙여 넣기만 하면 된다. → 이 조합회로를 레지스터(Register)라고 부른다

전원이 연결되어있는 한 이 회로는 정보를 저장할 수 있지만 전원이 끊기면 정보는 사라짐 → 메모리가 전원이 꺼지면 더이상 데이터를 저장할 수 없는 이유이다.

하드웨어의 기본 기술: 기계 명령

하나하나의 명령어에는 수많은 조합이 있을 수 있으므로 모든 경우를 기계 명령어로 설계하는 것은 불가능하다

⇒ CPU는 작동 방식이나 기능이고, 프로그래머는 명령어 집합을 이용해 전략을 제시하면 된다

소프트웨어와 하드웨어 간 인터페이스: 명령어 집합(Instruction set)

  • 명령어 집합: CPU가 실행할 수 있는 명령어와 피연산자를 묶은 것
  • 고급 프로그래밍 언어는 컴파일러를 통해 기계 명령어로 변환되는데, 이 집합을 명령어 집합이라 함
반응형
반응형

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

 

3.4.1 힙 영역이 필요한 이유

  • 프로그래머가 수명 주기를 포함하여 완전히 직접 제어할 수 있는 매우 큰 메모리 영역
  • 어떻게 힙 영역에 메모리를 할당하고 해제하는지 구현

3.4.2 malloc 메모리 할당자 직접 구현하기

  • 메모리 할당자는 적절한 크기의 메모리 영역을 제공하기만 하고 무엇을 저장할지 신경쓰지 않음
    • integer, floating number 등 신경쓰지 않고 단순한 바이트의 연속에 지나지 않음
    • 커다란 배열 형태

힙 영역 위에서 두가지 문제 해결

  1. malloc 함수 구현
    1. 메모리 영역을 요쳥하면 힙 영역에 가능한 메모리 영역 찾아 요청자에게 반환
  2. free 함수 구현
    1. 메모리 영역의 사용이 완료되었을 때 메모리 영역을 반환하는 방법 구현

 

3.4.3 주차장에서 메모리 관리까지

주차장에 비유하여 표현하면 2가지 목표를 만족시켜야 함

  1. 주차할 위치를 빠르게 찾음. 요청된 크기를 만족하는 여유메모리를 빨리 찾음
  2. 주차장 사용률 극대화. 메모리를 요청할 때 가능한 많은 메모리 할당 요청을 만족시켜야 함

4가지 문제점

  1. 메모리 조각의 할당 상태 추적
  2. 단일 메모리 할당 요청의 요구사항을 만족하는 사용 가능한 메모리 조작이 매우 많을 때 할당 기준
    1. 6바이트 요청했는데 여유 메모리 공간이 16, 32, 8 바이트 일때
  3. 16바이트 메모리를 요청해야 하는데 여유 메모리 조각의 크기가 32바이트라서 16바이트가 남는 경우
  4. 사용 반환된 메모리를 어떻게 처리해야 하나?

3.4.4 여유 메모리 조각 관리하기

  • 위의 4가지 문제점들 중 1번 해결을 위한 방법으로 linked list로 메모리 사용정보를 기록하는 방식

2가지 정보를 기록

  • 메모리 조각의 크기를 기록한 숫자 - header라고 하며 32비트
    • 메모리 조각이 비어있는지 알려주는 설정값(flag) - 1비트
  • 할당 가능한 메모리 조각을 payload라고 함 (메모리 주소)

3.4.5 메모리 할당 상태 추적하기

한조각은 4바이트

  • 16/1: 할당된 메모리 조각 크기가 16바이트를 의미
  • 32/0 : 여유 메모리 조각이 32 바이트
  • 0/1: 끝을 표시하는 특수한 표시로 4바이트

이 방식으로 메모리 조각의 여유/할당 상태 파악 및 추적할 수 있음

3.4.6 어떻게 여유 메모리 조각을 선택할 것인가: 할당 전략

  • 4바이트 메모리를 요청받을 때 요구사항을 충족하는 여유 메모리가 두조각이 있음
    • 8/0, 32/0 중 어떤것을 반환할 것인가? 다양한 할당 전략 방식이 있음

메모리 할당 전략 방식

1. 최초 적합 방식

  • 제일 앞부터 탐색하다가 가장 먼저 발견된 요구사항을 만족하는 항목을 반환
  • 단순하지만, 처음부터 사용가능한 메모리 조각을 찾으므로 앞부분에 작은 메모리 조각이 많이 남을 가능성이 높음

2. 다음 적합 방식

  • 최초 적합 방식과 유사하지만 메모리를 요청할 때 처음부터 검색하는 대신 적합한 여유 메모리 조각이 마지막으로 발견된 위치에서 시작한다는 점이 다름
  • 탐색 시작 위치가 달라져, 더 빠르게 여유 메모리 조각을 탐색할 수 있음
  • 단점: 메모리 사용률은 최초 적합 방식에 미치지 못한다는 것이 연구로 밝혀짐

3. 최적 적합 방식

  • 사용 가능한 메모리 조각을 모두 찾은 후 요구 사항을 모두 만족하면서 크기가 가장 작은 조각 반환
  • 메모리를 더 잘 활용한다는 장점이 있음
  • 사용가능한 모든 메모리 조각을 탐색하기 때문에 빠르지 않음

실제로는 더 복잡한 원리로 적합한 방식을 찾아 할당

3.4.7 메모리 할당하기

  • 12바이트 메모리를 요청했을 때 적절한 여유 메모리 조각이 12 바이트라고 가정
    • 헤더인 4바이트를 제외한 나머지 크기

  • 할당된 것으로 표시하고 머리 정보 뒤에 따라오는 메모리 조각의 주소를 요청자에게 반환하면 됨
  • 위는 이상적인 경우로 12바이트 메모리를 요청했을 때 찾아낸 여유 메모리 조각의 크기가 더 큰 경우가 대부분
  • 아래와 같이 찾아낸 메모리 조각이 32바이트라면? 메모리가 낭비되고 내부 단편화(fragmentation) 발생함

내부 단편화의 예

  • 이 문제를 해결하기 위해 여유 메모리 조각을 두개로 분할하여 앞부분은 할당한 후 반환하고 뒷부분은 좀 더 작은 크기의 새로운 여유 메모리 조각으로 만듦

3.4.8 메모리 해제하기

  • 사용자가 메모리를 요청할 때 얻은 주소를 ADDR이라고 가정
  • free(ADDR)을 호출하면 매개변수인 ADDR에서 머리 정보 크기인 4바이트를 빼는 것으로 메모리 조각의 머리 정보 얻을 수 있음
  • 머리 정보에서 얻은 할당 설정값을 여유 메모리로 바꾸면 해제가 완료됨

메모리 해제 시 중요한 점 하나

  • 해제되는 메모리 조각과 인접한 메모리 조각이 여유 메모리 조각일 때 단순히 해제 여부만 기록하면 아래 상황 발생

  • 해제된 메모리 조각에 인접한 아래쪽 메모리 조각도 비어있음
  • 메모리를 해제 할 때 둘을 합치는게 나을까? 그대로 두는게 나을까?

그대로 두는 경우

  • 20 바이트 요청이 오는 경우 두 조각 중 어느 것도 요구사항을 만족시키지 못함

메모리를 해제 할 때 메모리 조각을 병합할 때

  1. 즉시: 비교적 간단하지만 부담이 발생. 하지만 간단해서 여전히 이 전략을 많이 선택
  2. 요구사항을 충족하는 여유 블록을 찾을 수 없을 때: 실제 메모리 할당자가 선택하는 전략

3.4.9 여유 메모리 조각을 효율적으로 병합하기

  • 해제하는 메모리 조각의 위 아래가 모두 비어있다면? 아래는 헤더 정보로 알 수 있지만 위의 정보는 어떻게 알 수 있나?

  • 메모리 조각 끝에 footer 정보를 추가

  • 조각의 꼬리 정보는 그 다음에 위치한 조각의 머리 정보와 인접해 있어 현재 조각의 머리 정보에서 4바이트를 빼면 이전 조각의 꼬리 정보를 획득할 수 있음

  • header와 footer 정보는 메모리 조각을 doubly linked list로 만듦
반응형
반응형

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

3.2 프로세스는 메모리 안에서 어떤 모습을 하고 있을까?

3.2.1 가상 메모리: 눈에 보이는 것이 항상 실제와 같지는 않다

  • 모든 프로세스의 코드 영역은 0x400000에서 시작
    • 서로 다른 두개의 프로세스가 메모리를 할당하기 위해 malloc을 호출하면 동일한 시작 주소를 반환할 가능성이 매우 높음

Ubuntu 에서 테스트

// malloc_test.c
#include <stdio.h>
#include <stdlib.h>

int main() {
    void *ptr = malloc(10);  // Allocate 10 bytes
    printf("Address returned by malloc: %p\\n", ptr);
    free(ptr);  // Don't forget to free the memory
    return 0;
}

$ ./malloc_test Address returned by malloc: 0x56342fa8d260
$ ./malloc_test Address returned by malloc: 0x55a3e5a9d010

다른 주소가 반환됨. 이유는? → ASLR 이라는 기술이 최근 OS에는 적용되어있음

ASLR (Address Space Layout Randomization)

  • 스택, 힙, 동적 라이브러리 영역의 주소를 랜덤으로 배치해서 공격에 필요한 target address를 예측하기 어렵게 만드는 기술

ASLR 기술을 끄고 테스트하면 같은 주소가 반환되는 걸 볼 수 있다

$ echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
$ ./malloc_test
$ Address returned by malloc: 0x40068c
$ ./malloc_test
$ Address returned by malloc: 0x40068c
  • 두 프로세스가 모두 같은 주소에 데이터를 쓸 수 있다는 의미인데 괜찮은가?

⇒ 주소값은 가짜 주소(가상 메모리 주소)이고 메모리에 조작이 일어나기 전 실제 물리 메모리 주소로 변경되기 때문에 문제 되지 않음

실제 물리 메모리 구조 예시

주목할 2가지 사항

  1. 프로세스는 동일한 크기의 chunk로 나뉘어 물리 메모리에 저장
    - 위 그림에서 힙 영역은 3개의 chunk로 나뉨
  2. 모든 조각은 물리 메모리 전체에 무작위로 흩어져 있음

보기에 아름답지 않지만 운영 체제가 프로세스에 균일한 가상의 주소 공간을 제공하는 것을 방해하지는 않음 → 가상 메모리와 물리 메모리 사이의 mapping을 나타내는 page table로 관리

3.2.2 페이지와 페이지 테이블: 가상에서 현실로

  • 각각의 프로세스에는 단 하나의 페이지 테이블만 있어야 함
  • mapping은 ‘page’ 라는 단위로 이루어짐
    • 프로세스의 주소 공간을 동일한 크기의 ‘조각’으로 나누고, 이 ‘조각’을 페이지(page)라고 부름
  • 그러므로 두 프로세스가 동일한 메모리 주소에 기록하더라도 페이지 테이블 통해 실제는 다른 물리 메모리 주소에 저장됨

 

3.3 스택 영역: 함수 호출은 어떻게 구현될까?

아래 코드의 문제점은?

void func(int a)
{
	if(a>100000000)
	{
		return;
	}
	
	int arr[100] = { 0 };
	
	func(a + 1);
}
  • 함수 실행 시간 스택(runtime stack)과 함수 호출 스택(call stack) 이해 필요

3.3.2 함수 호출 활동 추적하기: 스택

  • A, B, C, D 4가지 단계가 존재하고 아래그림 처럼 의존성을 가짐

단계별로 진행 과정

  • A→B→D→B→A→C→A
  • 선입 선출(Last In First Out, LIFO) 순서로 stack 과 같은 데이터 구초가 처리하기 적합

 

3.3.3 스택 프레임 및 스택 영역: 거시적 관점

  • 함수 실행시의 자신만의 ‘작은 상자’가 필요한데 이를 call stack 혹은 stack frame 이라고 함
  • 스택은 낮은 주소 방향으로 커지므로 아래와 같이 표현됨

  • stack frame에는 어떤 정보들이 포함되는지?

3.3.4 함수 점프와 반환은 어떻게 구현될까?

  • 함수 A가 함수 B를 호출하면, 제어권이 A에서 B로 옮겨짐
    • 제어권: CPU가 어떤 함수에 속하는 기계 명령어를 실행하는지 의미

제어권이 넘어갈 때는 2가지 정보가 필요함

  1. 반환(return): 어디에서 왔는지에 대한 정보
    • 함수 A의 명령어가 어디까지 실행되었는지에 대한 정보
  2. 점프(jump): 어디로 가는지에 대한 정보
    • 함수 B의 첫 번째 기계 명령어가 위치한 주소

위의 정보는 어디서 가져오는지? → 스택 프레임!

함수 A가 함수 B를 호출할 때 CPU는 함수 A의 기계 명령어(주소: 0x400564)를 실행중이라 가정

  • CPU는 다음 기계 명령어를 실행하는데 call 뒤에 명령어 주소가 함수 B의 첫번째 기계 명령어
    • 이 명령어를 실행한 직후 CPU는 함수 B로 점프하게 됨
  • 함수 B실행이 완료되면 어떻게 함수 A로 돌아오나?
    • call 명령어 실행 후 지정한 함수로 점프 + call 명령어 다음 위치 주소(0x40056a)를 함수 A의 스택 프레임에 넣음

call 명령어를 실행하면 반환주소가 스택 프레임에 저장됨

반환 주소가 추가되면서 스택 프레임이 아래 방향으로 조금 커짐

  • 함수 B에 대응하는 기계 명령어를 실행하면서 B에 대한 스택 프레임도 추가됨
  • 함수 B의 마지막 기계 명령어인 ret은 CPU에 함수 A의 스택 프레임에 저장된 반환주소로 점프하도록 전달하는 역할

 

3.3.5 매개변수 전달과 반환값은 어떻게 구현될까?

대부분의 경우 레지스터를 통해 매개변수와 반환값을 전달

  • 함수 A가 B를 호출하면, A는 매개변수를 상응하는 레지스터에 저장
  • CPU가 함수 B를 실행할 때 이 레지스터에서 매개변수 정보를 얻을 수 있음
  • CPU 내부의 레지스터 수가 제한되는 경우 나머지는 스택 프레임에 넣음

스택 프레임에 함수 호출에 필요한 매개변수 보관

3.3.6 지역 변수는 어디에 있을까?

  • 레지스터에 저장할 수 있지만 로컬 변수가 레지스터 수보다 많으면 스택프레임에 저장

 

3.3.7 레지스터의 저장과 복원

  • 함수 A와 B가 지역변수 저장을 위해 모두 레지스터를 사용하면 값이 덮어써 질 수 있음
  • 그렇기 때문에 초기에 저장된 값을 레지스터에서 사용하고 나면 다시 그 초기값을 함수의 스택 프레임에 저장해야 함

 

3.3.8 큰 그림을 그려보자, 우리는 지금 어디에 있을까?

void func(int a)
{
	if(a>100000000)
	{
		return;
	}
	
	int arr[100] = { 0 };
	
	func(a + 1);
}
  • 앞에서 본 코드를 다시 보면, 자기 자신 함수를 100000000번 호출
  • 호출 할 때마다 스택 프레임이 함수 실행시 정보를 저장하기 위해 생성되며, 함수 호출 단계가 증가하며 스택 영역이 점점 더 많은 메모리 차지 → stack overflow 발생

위 과정 시각화 링크

 

Python Tutor code visualizer: Visualize code in Python, JavaScript, C, C++, and Java

Please wait ... your code is running (up to 10 seconds) Write code in Python 3.11 [newest version, latest features not tested yet] Python 3.6 [reliable stable version, select 3.11 for newest] Java C (C17 + GNU extensions) C++ (C++20 + GNU extensions) JavaS

pythontutor.com

  • 코드가 실행되면서 stack frame이 계속 쌓이는 모습을 볼 수 있음 

 

반응형
반응형

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

3.1 메모리의 본질, 포인터와 참조

3.1.1 메모리의 본질은 무엇인가? 사물함, 비트, 바이트, 객체

  • 메모리는 메모리 셀(memory cell)로 구성되며 셀에는 0과 1 (1bit) 가 보관될 수 있음
  • 1bit로는 표현할 수 있는 정보가 너무 적기 때문에 8개를 묶어 byte로 표현
  • byte는 메모리 내 자신의 주소를 가지며 memory address 라고 함
  • 1byte도 $2^{8}$=256 개의 조합만 만들 수 있으므로 더 많은 정보를 포함하기에는 부족
  • 12바이트를 묶어서 사용하며 프로그래밍 언어에서는 구조체(structure)또는 객체(object)라고 표현

 

3.1.2 메모리에서 변수로: 변수의 의미

  • java script/python 등의 고급 프로그래밍 언어는 사용할 수 없고 8byte만 사용가능한 메모리가 주어진다면? 메모리 읽기와 쓰기를 직접 수행해야 함
 

  • 8칸의 메모리 공간이 있고 옆에 붙은 번호가 각각 메모리의 주소

1 + 2 값을 계산하고 싶다고 가정하면,

  • 숫자 1과 2를 메모리에 저장해야 함 (메모리 값을 읽어 레지스터에 저장해야 CPU가 연산 수행 가능)
  • 숫자 1을 메모리 주소 6에 넣기 위해 store라는 명령어를 사용한다면,
store 1 6
  • 숫자 2개중 하나는 저장할 숫자 값이고 하나는 메모리 주소

쓰기를 위해 load 라는 명령어를 사용한다면,

load r1 6
  • 명령어 뜻 모호
    • 숫자 6을 r1 레지스터에서 읽는거지,
    • 메모리 주소 6에 저장된 숫자를 r1레지스터에 읽는건지,

즉, 메모리 주소와 숫자 값을 구분하기 위한 방법 필요

  • 예를 들어 $ 기호가 붙어 있으면 값이고, $ 기호가 없다면 메모리 주소를 의미
store $1 6
load r1 6

메모리 주소 6에 1의 값을 저장

  • 이제 주소 6은 숫자 1을 나타냄 (주소 6 → 숫자 1)
  • 하지만 ‘주소 6’이라는 표현은 익숙하지 않기 때문에 쉬운 별칭 필요 (a 같은) ⇒ 변수
a = 1

a 변수라는 의미

  • 값 1을 나타냄
  • 값은 메모리 주소 6에 저장됨
b = a

위 표현은? b변수를 위해 메모리 주소를 하나 할당할 수 있음

하지만 아래그림처럼 a 변수가 여러 바이트(5bytes)를 차지하는 데이터를 나타내고 b=a 를 표현해야 한다면, 메모리 공간이 부족한데 방법은?

3.1.3 변수에서 포인터로: 포인터 이해하기

  • b 변수가 a변수를 가리키고 있다면 데이터의 복사본을 만들 필요가 없음

  • a 변수는 메모리 주소 3에 위치하므로 b 변수에 메모리 주소 숫자 3을 저장 → 포인터

포인터를 메모리 주소로만 이해한다면 간접 주소 지정을 알아야 함 (indirect addressing)

load r1 1 
  • 메모리 주소 1에 있는 값(숫자 3)을 r1 레지스터에 적재하는데, 메모리 주소 1에는 주소값이 있기 때문에 이를 나타내 줘야 함
load r1 @1
  • 메모리 주소1 → 메모리 주소 3→ 데이터 값(1)

어셈블리어에는 변수라는 개념이 없기 때문에 위와 같이 간접주소 지정 계층을 알아야 하지만 고급 언어에서는 변수를 사용하면 됨

메모리 주소 1 -> 메모리 주소 3 -> 데이터 # 어셈블리어 수준
b -> 데이터 # 고급언어 수준

3.1.4 포인터의 힘과 파괴력: 능력과 책임

#include <stdio.h>

void main()
{
	int a = 1;
	printf("variable a is in %p\\n", &a);
}

variable a is in 0x7fff328

  • 포인터를 통해 메모리를 직접 조작할 수 있는 능력은 강력하지만, 모든 상황에 필요한 것은 아니며, 포인터 없는 프로그래밍 언어에도 대신 사용할 수 있고 포인터를 한번 더 추상화한 참조가 있음

3.1.5 포인터에서 참조로: 메모리 주소 감추기

  • 포인터는 메모리를 추상화하 것이고 참조는 포인터를 한번 더 추상화 한 것
반응형
반응형

event-based concurrency를 이용한 event-driven programming은 아래 2가지가 필요

  1. 이벤트: 네트워크 데이터의 수신 여부, 파일의 읽기 및 쓰기 가능 여부 등의 관심 대상
  2. 이벤트를 처리하는 함수: 이벤트 핸들러(event handler) 라고 함
 
while (true)
{
	event = getEvent(); // 이벤트 수신 대기
	handler(event);     // 이벤트 처리 
}

해결해야 할 2가지 문제

  • 첫번째 문제: getEvent 같은 함수 하나가 어떻게 여러 이벤트를 가져올 수 있나?
  • 두번째 문제: 이벤트를 처리하는 handler 함수가 반드시 이벤트 순환과 동일한 스레드에서 실행되어야 하나?

첫번째 문제: 이벤트 소스와 입출력 다중화

  • 리눅스와 UNIX 세계에서 모든 것이 파일로 취급됨
  • 프로그램은 file descriptor(fd)를 사용하여 입출력 작업을 실행(socket도 예외 아님)

동시에 fd 여러개를 처리하기 위해서는?

ex) 사용자 연결이 10개고 이에 대응하는 소켓 서술자가 열개 있는 서버가 데이터 수신 대기중

recv(fd1, buf1);
recv(fd2, buf2);
recv(fd3, buf3);
recv(fd4, buf4);

  • 첫번째 줄 코드에서 사용자가 데이터를 보내지 않는한 recv(fd1, buf1) 는 반환되지 않으므로 서버가 두 번째 사용자의 데이터를 수신하고 처리할 기회가 사라짐

 

더 좋은 방법은?

  • 운영체제에게 내용을 전달하는 작동 방식 사용
    • ‘저 대신 소켓 서술자 열 개를 감시하고 있다가, 데이터가 들어오면 저에게 알려주세요’
  • 입출력 다중화라고 하며 리눅스 세계에서 가장 유명한 것이 epoll
// epoll 생성
epoll_fd = epoll_create();

// 서술자를 epoll이 처리하도록 지정
Epoll_ctl(epoll_fd, fd1, fd2, fd3, fd4, ...);

while(1)
{
	int n = epoll_wait(epoll_fd); // getEvent 역할로 지속적으로 다양한 이벤트 제공
	
	for(i=0;i<n;i++)
	{
		// 특정 이벤트 처리
	}
	
	
}
  • epoll은 이벤트 순환(event loop)을 위해 탄생했음

위 사진처럼 epoll을 통해 이벤트 소스 문제가 해결됨

두번쨰 문제: 이벤트 순환과 다중 스레드

이벤트 핸들러에 2가지 특징이 있다고 가정

  1. 입출력 작업이 전혀 없음
  2. 처리함수가 간단해서 소요시간이 매우 짧음

이 경우 간단히 모든 요청을 단일스레드에서 순차적으로 처리가능

하지만 CPU시간을 많이 소모하는 사용자 요청을 처리해야 한다면?

  • 요청의 처리 속도를 높이고 다중 코어를 최대한 사용하려면 다중 스레드의 도움이 필요

 

  • 이벤트 핸들러는 더 이상 이벤트 순환과 동일한 스레드에서 실행되지 않고 독립적인 스레드에 배치됨
    • 이벤트 순환은 요청을 수신하면 간단한 처리 후 바로 각각의 작업자 스레드에 분배
    • 작업자 스레드를 thread pool로 구현하는 것도 가능

⇒ 이러한 설계 방법을 reactor pattern(반응자 패턴)이라고 부름

카페/음식점에서 주문 받는 사람(이벤트 순환)과 요리하는 사람(작업자 스레드)을 다르게 운영하는 것과 같다. 

 

이벤트 순환과 입출력

요청 처리 과정에 입출력 작업도 포함된다고 가정하면 2가지 상황 발생 가능

  1. 입출력 작업에 대응하는 논 블로킹 인터페이스가 있는 경우
    1. 직접 논블로킹 인터페이스를 호출해도 스레드가 일시 중지 않고, 인터페이스가 즉시 반환되므로 event loop에서 직접 호출하는 것이 가능
  2. 입출력 작업에 블로킹 인터페이스만 있는 경우
    1. 이때는 절대로 event loop에서 어떤 블로킹 인터페이스도 호출하면 안됨
    2. 호출하게 된다면 순환 스레드가 일지 중지될 수 있음
    3. 블로킹 입출력 호출이 포함된 작업은 작업자 스레드에 전달해야 함

 

비동기와 콜백 함수

  • 비지니스가 발전하면서 서버 기능은 점점 복잡해지고 여러 서버가 조합되어 하나의 사용자 요청을 처리
void handler(request)
{
	A;
	B;
	GetUserInfo(request, response); // A 서버에 요청 후 응답을 받아 매게변수 response에 저장
	C;
	D;
	GetQueryInfo(request, response); // B 서버에 요청 후 응답을 받아 매게변수 response에 저장	
	E;
	F;
	GetStorkInfo(request, response); // C 서버에 요청
	G;
	H;
}
  • Get 으로 시작하는 호출은 모두 블로킹 호출로 CPU 리소스를 최대한 활용하지 못할 가능성이 매우 높음 → 비동기 호출로 수정 필요
void handler_after_GetStorkInfo(response)
{
	G;
	H;
}

void handler_after_GetQueryInfo(response)
{
	E;
	F;
	GetStorkInfo(request, handler_after_GetStorkInfo); // 서버 C에 요청
}

void handler_after_GetUserInfo(response)
{
	C;
	D;
	GetQueryInfo(request, hanlder_after_GetQueryInfo); // 서버 B에 요청
}

void handler(request)
{
	A;
	B;
	GetUserInfo(request, handler_after_GetUserInfo); // 서버 A에 요청
}
  • 저 프로세스가 4개로 분할되었고 콜백 안에 콜백이 포함되어있음
  • 사용자 서비스가 더 많아지면 관리가 불가능한 코드 형태

비 동기 프로그래밍 효율성과 동기 프로그래밍의 단순성을 결합할 수 있다면? → 코루틴

 

코루틴: 동기 방식의 비동기 프로그래밍

  • 언어나 프레임워크가 코루틴을 지원하는 경우 handler 함수가 코루틴에서 실행되도록 할 수 있음
  • 코드 구현은 여전히 동기로 작성되지만 yield로 CPU제어권을 반환할 수 있음
  • 코루틴이 일시 중지 되더라도 작업자 스레드가 블로킹되지 않음 (코루틴과 스레드의 차이)
  • 코루틴이 일시중지되면 작업자 스레드는 다른 코루틴을 실행하기 위해 전환됨
  • 일시 중지된 코루틴에 할당된 사용자 서비스가 응답한 후 그 처리 결과를 반환하면 다시 준비상태가 되어 스케쥴링 차례가 돌아오길 기다림

코루틴 추가 후 서버의 전체 구조

  • 이벤트 loop은 요청을 받은 후 우리가 구현한 handler 함수를 코루틴에 담아 스케쥴링과 실행을 위해 각 작업자 스레드에 배포
  • 작업자 스레드는 코루틴을 획든한 후 진입 함수인 handler 를 실행
  • 어떤 코루틴이 CPU의 제어권을 반환하면 작업자 스레드는 다른 코루틴을 실행
    • 비록 코루틴이 블로킹 방식이더라도 작업자 스레드는 블로킹되지 않음

 

  • 스레드는 일반적으로 커널 상태 스레드라고 부름
    • 커널로 생성되고 스케쥴링을 함
    • 커널은 스레드 우선순위에 따라 CPU 연산 리소스를 할당
  • 코루틴은 커널 입장에서는 알 수 없는 요소로 코루틴 수는 커널과 무관하게 스레드에 따라 CPU시간 할당
    • 코루틴을 사용자 상태 스레드라고도 함
반응형

+ Recent posts