Post

스레드 안전, 코루틴


컴퓨터 구조

컴퓨터 구조를 공부하면서 알게된 내용을 요약해서 작성해보자.

스레드 안전

  • 다중 스레드 코드를 올바르게 작성하기 어려운 이유는 스레드 안전을 이해하지 못해서일 가능성이 높다.
  • 자유와 제약을 이해해야 한다.
  • 대부분 자신의 집에서는 자유로움을 느낀다. 집은 사적인 공간이자 내 행동에 대해 다른 사람이 간섭할 수 없기 때문이다.
  • 공공장소에서는 집에서 하던 것처럼 마음대로 행동할 수 없다. 공중 화장실을 가려면 줄을 서서 기다리는 등 규칙을 지켜야 한다. 공중 화장실은 누구나 사용할 수 있는 공공 자원이고, 이전 사람이 사용을 끝내야 다음 사람이 사용할 수 있다. 이것이 공공 자원을 사용할 때 적용되는 제약이다.
  • 스레드 안전을 단독으로 얘기할 때는 thread safety, 다른 단어를 수식할 때는 thread safe라고 한다.
  • 스레드 안전
    • 전용 리소스를 사용하는 스레드는 스레드 안전을 달성할 수 있다.
    • 공유 리소스를 사용하는 스레드는 다른 스레드에 영향을 주지 않도록 하는 대기 제약 조건에 맞게 공유 리소스를 사용하면 스레드 안전을 달성할 수 있다.
    • 어떤 코드가 주어졌을 때, 그 코드가 스레드가 호출되든, 어떤 순서로 호출되든 간에 상관없이 올바른 결과가 나오는 것을 스레드 안전이라고 말한다.
    • 즉, 단일 스레드에서 실행되든 다중 스레드에서 실행되든 올바른 결과가 나와야 한다.
      1
      2
      3
      4
      5
      6
      
      int func()
      {
        int a = 1;
        int b = 1;
        return a + b;
      }
      
  • 위 코드는 어디에서 호출하든 2를 반환한다. 이 코드는 스레드에 안전하다.
  • 스레드의 전용 리소스, 공유 리소스에는 어떤 것들이 있는지 파악해야 한다.
  • 공유 리소스는 정수처럼 단순한 변수일 수도 있고 구조체처럼 데이터일 수도 있다. 중요한 점은 이런 리소스를 여럿 리소스에서 읽고 쓸 수 있어야 하고, 이 조건을 만족해야만 공유 리소스라고 할 수 있다.

스레드 전용 리소스, 공유 리소스

  • 스레드 전용 리소스
    • 함수의 지역 변수
    • 스레드의 스택 영역
    • 스레드 전용 저장소
  • 공유 리소스
    • 힙 영역: 메모리의 동적 할당에 사용되는 영역, C/C++ 언어의 malloc 함수, new 예약어가 요청하는 메모리
    • 데이터 영역: 전역 변수가 저장되는 영역
    • 코드 영역: 읽기 전용으로, 프로그램이 실행되는 동안 코드를 수정 못한다.
    • 주로 힙 영역과 데이터 영역으로 구성된다.

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

1
2
3
4
5
6
int func()
{
    int a = 1;
    int b = 1;
    return a + b;
}
  • 이 함수는 전역 변수나 매개변수에 의존하지 않고 스레드 전용 리소스인 지역 변수만 사용한다.
  • 이런 변수는 실행된 후 스레드의 스택 영역에서 관리한다.
    • 개념적이기는 하지만, 스레드마다 독점적으로 사용할 수 있는 스택 영역이 있다.
  • 이런 코드를 무상태 함수(stateless function)라 하고, 이 코드가 스레드 안전이라는 것은 분명하다.

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

1
2
3
4
5
int func(int num)
{
    num++;
    return num;
}
  • 함수 매개변수를 값으로 전달하는 경우는 문제 없으며, 이 코드는 스레드 안전이다.
  • 포인터를 전달하면 상황이 달라진다.

전역 변수 사용

  • 전역 변수가 처음 프로그램이 실행될 때 한 번 초기화되고 나서 모든 코드가 변수를 읽기만 한다면 문제없다.
    1
    2
    3
    4
    5
    6
    
    int global_num = 100;
    int func()
    {
      ++global_num;
      return global_num;
    }
    
  • func 함수는 스레드 안전이 아니다.

스레드 전용 저장소

  • 전역 별수를 정의하는 global_num = 100; 앞에 __thread를 추가하면 다시 스레드 안전이 된다.
    1
    2
    3
    4
    5
    6
    
    __thread int global_num = 100;
    int func()
    {
      ++global_num;
      return global_num;
    }
    
  • __thread 수식어가 붙은 변수는 스레드 전용 저장소에 배치된다.
    • 스레드들에 각각 100씩 배치한다.

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

  • 다중 스레드 프로그래밍을 할 때 스레드 간에 어떤 리소스를 공유해야 하는지 고려해야 한다.
  • 스레드 간에 어떤 공유 리소스도 읽거나 쓰지 않는다면 스레드 안전 문제는 있을 수 없다.
  • 다중 스레드 프로그래밍 중에는 어떤 리소스라도 최대한 공유하지 않는 것이 원칙이다.
  • 스레드 전용 저장소(thread local storage): 전역 리소스를 사용해야 하는 경우 스레드 전용 저장소로 선언할 수 있는지 확인한다. 모든 스레드에서 사용할 수 있지만 각 스레드 마다 자체 복사본이 있으며, 변경해도 다른 스레드에 영향을 미치지 않기 때문이다.
  • 읽기 전용(read-only): 전역 리소스를 반드시 사용해야 한다면 읽기 전용으로 사용해도 되는지 확인한다.
  • 원자성 연산(atomic operation): C++ 언어의 std::atomic 형식의 변수처럼 원자성 연산은 도중에 중단되지 않는다. 이런 변수에 대한 연산에는 전통적인 방식의 잠금으로 보호가 필요하지 않다.
  • 동기화 시 상호배제(mutual exclusion in synchronization): 이 단계까지 내려왔다면, 한 번에 하나의 스레드만 공유 리소스에 접근할 수 있도록 스레드가 접근하는 공유 리소스 순서를 프로그래머가 어쩔 수 없이 직접 유지해야 하는 상황까지 내몰린 것이 확실하다. 뮤텍스(mutex), 스핀 잠금(spin lock), 세마포어(semaphore) 외에 작동 방식 모두 이 목적을 이루는 데 사용될 수 있다.
  • 스레드 안전 구현은 스레드 전용 리소스와 스레드 공유 리소스를 중심으로 진행된다. 먼저 어떤 것이 스레드 전용 리소스고 어떤 것이 스레드 공유 리소스인지 파악하고, 각 증상에 맞는 약을 처방하면 된다.

코루틴

  • 코루틴은 높은 성능과 동시성을 요구하는 분야에서 더 자주 등장하고 있다.
  • 코루틴과 일반 함수에 형식적인 차이는 없다. 하지만 코루틴에는 스레드와 매우 유사한 기능인 일시 중지와 재개 기능이 있다.
  • 코루틴은 자신의 실행 상태를 저장할 수 있기 때문에 코루틴이 반환된 후에도 계속 호출이 가능하다.
  • 마지막으로 일시 중지된 지점에서 다시 이어서 실행된다.
  • 일반 함수는 반환된 후 프로세스 주소 공간의 스택 영역에 더 이상 어떤 함수 실행 시 정보도 저장하지 않는다.
  • 코루틴이 반환될 때는 함수 실행 시 정보를 저장한다. 실행을 멈추었던 지점에서 다시 실행할 때 필요하기 때문이다.
  • 일반 함수 호출의 실행 흐름
    • 일반 함수 funcA에서 funcB 호출 -> 일반 함수 funcB 실행 -> funcA의 이후 실행
  • 코루틴 호출의 실행 흐름
    • 일반 함수 funcA에서 funcB 호출 -> 일반 함수 funcB 실행 -> 반환 -> funcA 이후 실행 -> 코루틴 호출 -> funcB에서 반환 지점 이후 실행 -> 반환 -> funcA 이후 실행 -> …
    • 코루틴이 시작되면 첫 번째 연결 시작 지점까지 실행 후 다시 funcA로 돌아간다.
    • funcA 함수는 실행 되다가 다시 코루틴을 실행한다.
    • 코루틴은 첫 번째 줄에서 다시 시작 되는 것이 아니라 연결 시작 지점부터 실행된다.
  • 코루틴이 일반 함수와 다른 점은 자신이 이전에 마지막으로 실행된 위치를 알 수 있다는 것이다.
  • 코루틴은 온전히 사용자 상태 내에서 구현된 것이기 때문에 코루틴을 사용자 상태 스레드로 해석할 수 있다.

코루틴의 역사

  • 코루틴의 개념은 1958년에 존재했다. 1972년에 이르러서 코루틴을 구현한 프로그래밍 언어가 등장했다.
    • 시뮬라 67, 스키마
  • 하지만 당시에는 코루틴은 거의 사용되지 않았다.
  • 스레드가 등장하고 운영 체제가 기본적으로 프로그램의 동시 실행을 지원하기 시작하면서 코루틴은 프로그래머 기억 속에서 조용히 사라져갔다.
  • 인터넷이 발달하고, 모바일 인터넷 시대가 되면서 서버에서 처리해야 하는 사용자 요청이 기하급수적으로 늘어나기 시작했다.
  • 코루틴은 높은 성능과 동시성을 요구하는 분야에서 자신의 위치를 찾았다.

코루틴 구현

  • 코루틴의 구현은 스레드의 구현과 본질적으로 차이가 없다.
  • 코루틴은 일시 중지되거나 다시 시작될 수 있다.
  • 일시 중지될 때의 상태 정보를 반드시 기록해야 한다. 이를 기반으로 코루틴을 다시 시작해야 한다.
  • 코루틴은 힙 영역에 배치되어 있다. 이와 같은 이유로 수시로 코루틴을 일시 중지하거나 재개할 수 있다.
  • 이론적으로 메모리 공간이 충분하다면 코루틴 개수에 제한은 없다.
  • 코루틴 간 전환이나 스케줄링은 전적으로 사용자 상태에서 일어나기 때문에 운영 체제가 개입할 필요가 없다.
  • 코루틴 간에 전환할 때 저장 또는 복구되는 정보도 더 가볍기 때문에 효율성도 훨씬 높다.
  • 코루틴의 중요한 역할 중 하나는 프로그래머가 동기 방식으로 비동기 프로그래밍을 가능하게 한다.
  • 콜백 함수, 동기화, 비동기화, 블로킹, 논블로킹 같은 개념을 철저하게 이해하는 것이 프로그래머에게 큰 도움이 된다.