Blog
책 리뷰
컴퓨터 밑바닥의 비밀
Sotuc Chapter 2

컴퓨터 밑바닥의 비밀 Chapter 2 : 프로그램이 실행되었지만, 뭐가 뭔지 하나도 모르겠다

2.1 운영 체제, 프로세스, 스레드

2.1.1 모든 것은 CPU에서 시작된다

  • CPU는 사실 스레드, 프로세스, 운영 체제 같은 개념을 전혀 알지 못함.
  • CPU는 메모리에서 명령을 하나 가져와서 이 명령을 실행한 후 다시 명령을 가져오는 작업만을 함.
  • CPU는 PC(Program Counter)라고 불리는 레지스터에서 명령어를 가져옴.
  • PC 레지스터는 값을 하나씩 옮기거나 아니면 JMP 연산을 통해서 다음 PC 레지스터의 값을 변경함.
  • 최초의 PC 레지스터 값은 메모리에 저장되어 있는데, 메모리에 저장된 명령어는 디스크에 저장된 실행 파일에서 적재되고 그 실행 파일은 컴파일러로 생성됨.

2.1.2 CPU에서 운영 체제까지

  • 프로그램을 실행시키기 위해서는 프로그램을 적재할 수 있는 적절한 크기의 메모리 영역을 찾아야 함.
    • CPU 레지스터를 초기화하고 함수의 진입 포인트(Entry Point)를 찾아 PC 레지스터를 설정해야 함.
    • 한 번에 하나의 프로그램만 실행 가능함.
    • 모든 프로그램은 사용할 하드웨어를 직접 특정 드라이버와 연결해야 하며, 그렇지 않으면 프로그램이 외부 장치를 전혀 사용할 수 없음.
    • printf 같은 함수를 직접 구현해야 함.
    • 위 같은 상황을 해주는 적재 도구(loader)를 개발.
    • 하나의 코어로도 A 프로그램을 실행하다가 B 프로그램을 실행하면 동시에 실행하는 것처럼 보임.
    • 실행 중인 프로그램을 관리해야할 필요성이 생김.
    • 프로그램이 어디까지 실행중인이 어떤 데이터를 담고 있는지 등을 담을 상황 정보(context)가 필요해짐. 👉 프로세스라고 명명.
  • 👉 이러한 동작을 해주는 것을 운영체제라고 명명함.

2.1.3 프로세스는 매우 훌륭하지만, 아직 불편하다

  • 동일 프로세스 내에서 A라는 함수가 있고 B라는 함수가 있다고 할 때 A 함수가 3분이 걸리고 B 함수가 4분이 걸리면 직렬처리시 7분이라는 시간이 걸림.
    • A 라는 함수를 프로세스 A로 만들고 B라는 함수를 프로세스 B로 만들어 병렬처리를 하면 4분이 걸리지만 두 값을 이용해서 뭔가 연산을 한다면 프로세스 간의 통신이 필요함.
      • 프로세스 생성은 큰 비용임.
      • 프로세스마다 자체적인 주소 공간을 가지고 있기 때문에 프로세스 간 통신은 프로그래밍하기에 더 복잡함.

2.1.4 프로세스에서 스레드로 진화

  • 하나의 프로세스에 속한 기계 명령어를 CPU 여러 개에서 동시에 실행할 수 있음.
    • CPU 여러 개가 한 지붕 아래에 있는 것과 마찬가지로 공유 프로세스 주소 공간에서 동일한 프로세스에 속한 명령어를 동시에 실행 가능.
    • 다시 말해 하나의 프로세스 안에 여러 실행 흐름이 존재할 수 있음.
    • 👉 이러한 실행 흐름을 **스레드(thread)**라고 부름.
  • 다중 스레드는 상호 배제와 동기화를 이용해서 공유 리소스 문제를 명시적으로 직접 해결해야 한다는 문제가 발생.

2.1.5 다중 스레드와 메모리 구조

  • 모든 스레드는 각자 자신만의 스택 영역을 가지는데, 스레드가 이를 인지하고 있는 것이 매우 중요함.

2.1.6 스레드 활용 예

  • 서버가 하나의 요청을 받으면 해당 작업을 처리하는 스레드를 생성하고 처리가 완료되면 스레드를 종료하면 된다고 간단하게 생각할지도 모름.
    • 이 방법은 일반적으로 요청당 스레드(thread-per-request)라고 부름.
    • 요청이 들어올 때마다 매번 스레드가 생성된다는 의미로 긴 작업을 대상으론 잘 동작하지만 대량의 짧은 작업에서는 구현이 간단하지만 문제점이 있음.
      • 스레드의 생성과 종료에 많은 시간을 허비.
      • 스레드마다 각자 독립적인 스택 영역이 필요한데, 많은 수의 스레드를 생성하면 메모리와 기타 시스템 리소스를 너무 많이 소비함.
      • 스레드 수가 많으면 스레드 간 전환(thread context switch)에 따른 부담이 증가함.
  • 👉 이러한 문제를 해결하기 위해 스레드 풀(thread pool)이 탄생.

2.1.7 스레드 풀

  • 스레드 여러 개를 미리 생성해 두고, 스레드가 처리할 작업이 생기면 해당 스레드에 처리를 요청하는 것.
  • producer consumer 패턴과 유사함.

2.1.8 스레드 풀의 스레드 수

  • CPU 바운드에선 CPU의 코어수와 거의 동일하게 IO Bound에선 테스트 결과에 따라 다르게 하셈.
  • CPU 바운드는 스레드 수가 적은게 좋지만 IO Bound는 스레드 수가 많은게 좋음.

2.2 스레드 간 공유되는 프로세스 리소스

  • 아래 두 질문으로 시작.
    • 스레드 간에는 어떤 프로세스 리소스를 공유하고 있을까?
    • 스레드 전용 리소스에는 어떤 것이 있을까?

2.2.1 스레드 전용 리소스

  • 상태 변화 관점에서 보면 스레드는 사실 함수 실행임.
  • 함수의 실행 시간 정보는 스택 영역을 구성하는 스택 프레임에 저장됨.
  • 스택영역은 다음에 실행될 명령어 주소를 저장하는 PC 레지스터, 스레드 스택 영역에서 스택 상단(stack top) 위치를 저장하는 스택 포인터(stack pointer) 등으로 구성.
  • 스레드는 프로세스 주소 공간에서 스택 영역을 제외한 나머지 영역을 모두 공유함.

2.2.2 코드 영역: 모든 함수를 스레드에 배치하여 실행할 수 있다

  • 프로세스 주소 공간의 코드 영역에는 프로그래머가 작성한 코드, 더 정확하게는 컴파일한 후 생성된 실행 가능한 기계 명령어가 저장됨.
  • 코드 영역은 스레드 간에 공유되므로 어떤 함수든지 모두 스레드에 적재하여 실행할 수 있음.
  • 코드 영역은 읽기 전용임.

2.2.3 데이터 영역: 모든 스레드가 데이터 영역의 변수에 접근할 수 있다

  • 데이터 영역은 전역 변수가 저장되는 곳임.
  • 스레드간 공유됨.

2.2.4 힙 영역: 포인터가 핵심이다

  • C/C++ 언어에서 malloc 함수와 new 예약어로 요청하는 메모리가 이 영역에 할당됨.
  • 스레드간 공유됨.

2.2.5 스택 영역: 공유 공간 내 전용

  • 실제 구현 측면에서 바라본다면 스택 영역은 엄밀하게 격리된 스레드 전용 공간은 아님.
  • 프로세스 간에는 다른 프로세스의 주소 공간에 직접 접근하지 못하도록 보장하지만 서로 다른 스레드의 스택 영역 간에는 이런 보호를 위한 작동 방식이 존재하지 않음.
  • 하나의 스레드가 다른 스레드의 스택 포인터를 가져올 수 있다면 해당 스레드는 다른 스레드의 스택 영역을 직접 읽고 쓸 수 있음.

2.2.6 동적 링크 라이브러리와 파일

  • 정적 프로그램은 시작할 때 적재되기 때문에 추가적인 작업이 필요하지 않지만 동적 링크는 프로그램을 시작할 때 또는 실행 중일 때 종속된 라이브러리의 코드와 데이터를 찾아서 프로세스 주소 공간에 넣는 링크 과정이 완료되어야 함.
  • 이는 스택과 힙 사이의 여유 공간에 들어가며 모든 스레드가 공유해서 쓸 수 있음.

2.2.7 스레드 전용 저장소

// __thread 키워드를 사용하면 스레드 전용 저장소를 사용할 수 있음.
__thread int a = 1;
  • 이 영역에 저장된 변수는 모든 스레드에서 접근할 수 있지만 변수의 인스턴스는 각각의 스레드에 속함.
  • 따라서 하나의 스레드에서 변수 값을 변경해도 다른 스레드에는 반영되지 않음.

2.3 스레드 안전 코드는 도대체 어떻게 작성해야 할까?

2.3.1 자유와 제약

  • 전용 리소스를 사용하는 스레드는 스레드 안전을 달성할 수 있음.
  • 공유 리소스를 사용하는 스레드는 다른 스레드에 영향을 주지 않도록 하는 대기 제약 조건에 맞게 공유 리소스를 사용하면 스레드 안전을 달성할 수 있음.

2.3.2 스레드 안전이란 무엇일까?

  • 스레드들이 어떤 순서로 호출되든 간에 상관없이 올바른 결과가 나온다면, 이 코드를 스레드 안전이라고 함.

2.3.3 스레드 전용 리소스와 공유 리소스

  • 함수의 지역 변수, 스레드의 스택 영역, 스레드 전용 저장소는 스레드 전용 리소스이며 그 외 힙, 데이터 영역, 코드 영역은 공유 리소스임.

2.3.4 스레드 전용 리소스만 사용하기

int func() {
  int a = 1;
  int b = 1; 
  return a + b;
}
  • 위 코드처럼 지역 변수만 사용해서 스레드 안전을 달성할 수 있음.
  • 이런 코드를 무상태 함수라고 함.

2.3.5 스레드 전용 리소스와 함수 매개변수

  • 함수 매개변수를 값으로 전달(call by value)하는 경우라면 문제 없지만 참조로 전달(call by reference)하는 경우에는 문제가 발생할 수 있음.
int func(int* num) {
  ++(*num);
  return *num;
}
  • 포인터가 힙 영역에 저장된 동일한 변수를 가리키고 있다면, 해당 변수는 두 스레드가 공유하는 리소스로 간주됨.

2.3.6 전역 변수 사용

  • 만약 사용되는 전역 변수가 읽기만 한다면 스레드 안전과 관련 없지만 읽기와 쓰기가 모두 발생 한다면 스레드 안정을 보장할 수 없음.

2.3.7 스레드 전용 저장소

  • thread local storage(TLS)를 사용하면 스레드 안전을 달성할 수 있음.

2.3.8 함수 반환값

  • 함수가 값을 반환하는 경우가 return by value라면 스레드 안전을 보장할 수 있지만 return by reference라면 스레드 안전을 보장할 수 없음.
    • 예시 👇
    • int* func() {
        int a = 1;
        return &a;
      }

2.3.9 스레드 안전이 아닌 코드 호출하기

  • 스레드 안전이 아닌 코드 앞에 락을 걸어서 스레드 안전을 보장할 수 있음.
int func() {
  mutex l;
 
  l.lock();
  int a = 1;
  l.unlock();
  return a;
}

2.3.10 스레드 안전 코드는 어떻게 구현할까?

  • 스레드 전용 저장소(thread local storage, TLS) 사용.
  • 전용 리소스 읽기 전용(read-only)으로 사용.
  • 원자성 연산(atomic operation) 사용. (ex. std::atomic)
  • 동기화 시 상호 배제. (ex. mutex, semaphore)