본문 바로가기
C,C++/정보정리

[C/C++] Thread - (1)

by 마두식 2023. 2. 15.
반응형
Thread

-  프로세스란, 운영체제에서 실행되는 프로그램의 최소 단위

=>  1개의 프로그램을 가리킬 때 보통 1개의 프로세스를 의미하는 경우가 많다.


-  과거에는 소비자용 CPU의 경우 1개의 코어를 가지는 것이 대부분이었다.

=>  CPU가 한 번에 한 개의 연산을 수행한다.
==>  동시에 여러 작업을 하기 위해 컨텍스트 스위칭(Context Switching) 이라는 기술을 사용함.


-  CPU는 한 프로그램을 통째로 쭉 실행시키는 것이 아니라, 각 프로그램을 골라서 차례를 돌며 조금씩 실행시킨다.

=>  정확히는, CPU는 운영체제가 처리하라고 시키는 명령어들을 실행할 뿐이다.
=>  어떤 프로그램을 얼마나 실행시킬지, 다음에는 무슨 프로그램으로 스위치 할 지는 운영체제의 스케쥴러(scheduler)가 알아서 결정하게 된다.


-  CPU 코어에서 돌아가는 프로그램 단위를 쓰레드(thread)라고 부른다.

=> CPU의 코어 하나에서는 한 번에 한 개의 쓰레드의 명령을 실행시키게 된다.


-  한 개의 프로세스는 최소 한 개의 쓰레드로 구성되며, 여러 개의 쓰레드로 구성된 프로그램을 멀티 쓰레드(multithread) 프로그램 이라고 한다.

-  쓰레드와 프로세스의 가장 큰 차이점은 서로 메모리를 공유하냐 이다.

=>  프로세스는 서로 메모리를 공유하지 않는다.
=>  쓰레드는 서로 같은 메모리를 공유하게 된다.

 

  • 멀티 코어 CPU

-  멀티 코어 CPU에서는 여러개의 코어에 각기 다른 쓰레드들이 들어가 동시에 여러개의 쓰레드들을 효율적으로 실행할 수 있다.

=>  이전 싱글 코어 CPU에선 아무리 멀티 쓰레드 프로그램이라 하더라도 결국 한 번에 한 쓰레드만 실행할 수 있었다.

 

  • 멀티 쓰레드

-  멀티 쓰레드를 사용하는 것이 유리한 경우

1.  병렬 가능한(Parallelizable) 작업들
=>  어떠한 작업을 여러개의 다른 쓰레드를 이용해서 좀 더 빠르게 수행하는 것을 병렬화(parallelize)라고 한다.
==>  프로그램 논리 구조 상 연산들 간의 의존 관계가 많을 수록 병렬화가 어려워지고, 다른 연산 결과와 관계없이 독립적으로 수행할 수 있는 구조가 많을 수록 병렬화가 쉬워진다.
2.  대기시간이 긴 작업들
=>  웹사이트 다운로드
※  우리가 흔히 ping 이라고 부르는 것은, 내가 보낸 요청이 상대 서버에 도착해서 다시 나에게 돌아오는데 걸리는 시간을 의미한다.
보통 우리나라 안에서 웹사이트에 요청을 보낼 시 ping이 30 밀리초 정도 나오고, 해외의 경우 150 ~ 300 밀리초까지 걸리게 된다.

 

  • C++에서 쓰레드 생성

-  thread 헤더파일 추가 및 thread 객체 생성

=>  #include <thread>
=>  std::thread t1(func1);
==>  생성된 t1은 인자로 전달받은 함수 func1을 새로운 쓰레드에서 실행하게 된다.


-  생성한 쓰레드들이 CPU 코어에 어떻게 할당되고, 또 언제 컨텍스트 스위치 할 지는 전적으로 운영체제의 몫이다.

=>  운영체제가 어떻게 스케쥴 할 지는 매 상황에 맞게 바뀌기 때문에 결과를 정확히 예측할 수 없다.


-  join은 해당하는 쓰레드들이 실행을 종료하면 리턴하는 함수이다.

=>  t1.join();
==>  t1.join()의 경우 t1이 종료하기 전까지 리턴하지 않는다.
==>  t1보다 뒤에 작성된 t2나 t3가 먼저 종료돼도 전혀 상관없다.
==>  t1.join()이 끝나고 t2.join()을 하였을 때 쓰레드 t2가 이미 종료된 상태라면 바로 함수가 리턴된다.


-  join을 하지 않는다면(쓰레드 객체를 생성만 한다면), 쓰레드들의 내용이 실행되기 전에 main 함수가 종료되어 쓰레드 객체들의 소멸자가 호출된다.

=>  VS에서 실행해본 결과 그냥 런타임 에러 발생.
==>  "abort() has been called"


-  C++ 표준에 따르면 join 되거나 detach 되지 않은 쓰레드들의 소멸자가 호출된다면 예외를 발생시키도록 명시되어 있다.

-  detach란, 해당 쓰레드를 실행시킨 후 잊어버리는 것이라 생가갛면 된다.

=>  쓰레드는 알아서 백그라운드에서 돌아가게 된다.
=>  기본적으로 프로세스가 종료될 때, 해당 프로세스 안에 있는 모든 쓰레드들은 종료 여부와 상관없이 자동으로 종료된다.

 

  • 쓰레드에 인자 전달하기

-  쓰레드는 리턴값 이란 것이 없기 때문에 만일 어떠한 결과를 반환하고 싶다면 포인터의 형태로 전달하면 된다.

-  쓰레드 생성자의 첫번째 인자로 함수(정확히는 Callable은 다 됨)를 전달하고, 이어서 해당 함수에 전달할 인자들을 쭈르륵 작성하면 된다.

-  각 쓰레드에는 고유 아이디 번호가 할당된다.

=>  this_thread::get_id 함수를 통해 현재 내가 돌아가고 있는 쓰레드의 아이디를 알 수 있다.


-  쓰레드 사용 시 std::cout 주의

=>  std::cout << A; 를 하게되면, A의 내용이 출력되는 중간에 다른 쓰레드가 내용을 출력할 수 없게 보장해준다.(컨텍스트 스위치가 되어도 보장된다.)
=>  단, std::cout << A << B; 를 하게되면, A 출력 이후 B를 출력하기 전에 다른 쓰레드가 내용을 출력할 수 있다.
==>  printf는 "..." 안에 있는 문자열을 출력할 때, 컨텍스트 스위치가 되어도 다른 쓰레드들이 그 사이에 메세지를 삽입하지 못하게 막는다.

 

  • 경쟁 상태(race condition)

-  서로 다른 쓰레드에서 같은 자원을 동시에 사용할 때 발생하는 문제를 뜻한다.

※ CPU 간단 소개
CPU에서 연산을 수행하기 위해서는, CPU의 레지스터(register)라는 곳에 데이터를 기록한 다음 연산을 수행해야 한다.
레지스터의 크기는 매우 작으며, 개수도 적은 편이다. 일반적인 연산에서 사용되는 범용 레지스터의 경우 불과 16개 밖에 없다.
즉, 모든 데이터들은 메모리(RAM)에 저장되어 있고, 연산할 때마다 메모리에서 레지스터로 값을 가져온 뒤, 빠르게 연산을 끝내고 다시 메모리에 가져다 놓는 식으로 작동을 한다.

 

  • 뮤텍스(mutex)

-  상호 배제(mutual exclusion)에서 파생된 단어

-  mutex.lock() 은 뮤텍스를 내가 쓰게 해달라고 이야기 하는 것

=>  한 번에 한 쓰레드에서만 뮤텍스 객체의 사용 권한을 갖는다.
==>  다른 쓰레드에서 lock()을 한다면 뮤텍스 객체를 소유한 쓰레드가 unlock()을 통해 뮤텍스 객체를 반환할 때까지 무한정 기다린다.


-  mutex.lock()과 mutex.unlock() 사이에 한 쓰레드만이 유일하게 실행할 수 있는 코드 부분을 임계 영역(critical section)이라고 부른다.

-  lock()을 한 후 unlock()을 하지 않으면, 다른 모든 쓰레드들이 기다리게 되고 심지어 본인도 마찬가지로 lock()을 다시 호출하게 된다면 본인 역시 기다려야 할 수도 있다.

=>  이러한 상황을 데드락(deadlock)이라 부른다.


-  뮤텍스는 사용이 끝나면 반드시 반환해야 한다.

=>  비슷하게 사용된게 unique_ptr
=>  뮤텍스도 마찬가지로 사용 후 해제 패턴을 따르기 때문에 동일하게 소멸자에서 처리할 수 있다.


-  lock_guard 객체는 뮤텍스를 인자로 받아서 생성한다.

=>  생성자에서 뮤텍스를 lock하게 되며, lock_guard가 소멸될 때 알아서 lock 했던 뮤텍스를 unlock 하게 된다.

 

  • 데드락(deadlock)

-  데드락 발생 조건

1.  상호배제 : 프로세스들이 필요로 하는 자원에 대해 배타적인 통제권을 요구한다.
2.  점유대기 : 프로세스가 할당된 자원을 가진 상태에서 다른 자원을 기다린다.
3.  비선점 : 프로세스가 어떤 자원의 사용을 끝낼 때까지 그 자원을 뺏을 수 없다.
4.  순환대기 : 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있다.
=>  위 4가지 조건 중 하나라도 만족하지 않으면 데드락(교착 상태)은 발생하지 않는다.
==>  순환대기 조건은 점유대기 조건과 비선점 조건을 만족해야 성립하는 조건이므로, 위 4가지 조건은 서로 완전히 독립적인 것은 아니다.


-  데드락을 막기 위해 한 쓰레드가 다른 쓰레드에 비해 우위를 갖게 되면, 한 쓰레드만 열심히 일하고 다른 쓰레드는 일할 수 없는 기아 상태(starvation)가 발생할 수 있다.

-  C++에서 제공하는 try_lock() 함수는, 뮤텍스를 lock 할 수 있다면 lock을 하고 true를 리턴하며 lock 할 수 없다면 기다리지 않고 false를 리턴한다.

-  데드락 상황을 피하기 위한 가이드라인

1.  중첩된 Lock을 사용하는 것을 피해라
2.  Lock을 소유하고 있을 때 유저 코드를 호출하는 것을 피해라
3.  Lock들을 언제나 정해진 순서로 획득해라

 

  • 생산자(Producer)와 소비자(Consumer) 패턴

-  생산자란 무언가 처리할 일을 받아오는 쓰레드, 소비자란 받은 일을 처리하는 쓰레드

=>  인터넷에서 페이지를 긁어서 분석하는 프로그램을 만들었다고 했을 때, 페이지를 긁어 오는 쓰레드가 생산자
=>  위의 예제에서 긁어온 페이지를 분석하는 쓰레드가 소비자

 

  • condition_variable

-  특정 조건을 만족하면 쓰레드를 깨운다

-  condition_variable cv;

=>  뮤텍스를 정의할때와 같다.


-  wait 함수에 어떤 조건이 참이 될 때까지 기다릴지 해당 조건을 인자로 전달해야 한다.

=>  조건변수는 만일 해당 조건이 거짓이라면, 뮤텍스를 unlock 한 뒤 영원히 sleep 한다.
==>  쓰레드는 다른 누가 깨워주기 전까지 계속 sleep 된 상태로 기다린다.
==>  조건이 참이라면 wait는 그대로 리턴된다.


-  unique_lock

=>  기존의 lock_guard와 거의 동일하지만, lock_guard의 경우 생성자 말고는 따로 lock을 할 수 없었는데 unique_lock은 unlock 후에 다시 lock 할 수 있다.
=>  또한, cv->wait 함수가 unique_lock을 인자로 받는다.


-  cv->notify_one();

=>  조건이 거짓이어서 자고 있던 쓰레드 중 하나를 깨워서 조건을 다시 검사하게 해준다.
==>  조건이 참이라면 그 쓰레드가 다시 일을 시작한다.


-  마지막으로 cv->notify_all()을 통해 모든 쓰레드를 깨워서 조건을 검사하도록 한다.

=>  자고 있는 쓰레드들이 있을 수 있고, 해당 쓰레드들이 제대로 종료되지 않는 문제가 발생할 수 있다.

 

  • 메모리

-  기본적으로 CPU와 RAM은 물리적으로 떨어져있다.

=>  CPU가 메모리(RAM)에서 데이터를 읽어오기 위해 꽤 많은 시간을 필요로 한다.

 

  • 캐시

-  CPU 칩 안에 있는 조그마한 메모리

=>  RAM과는 달리 CPU에서 연산을 수행하는 부분이랑 거의 붙어있어서 읽기 / 쓰기 속도가 매우 빠르다


-  CPU가 특정 주소에 있는 데이터에 접근하려면, 일단 캐시에 있는지 확인 후 캐시에 있다면 해당 값을 읽고 없다면 메모리(RAM)까지 갔다 오는 방식으로 진행된다.

=>  캐시에 있는 데이터를 다시 요청해서 시간을 절약하는 것을 Cache hit
=>  캐시에 요청한 데이터가 없어서 메모리까지 갔다오는 것을 Cache miss


-  CPU가 캐시에 데이터를 저장하는 방법

1.  메모리를 읽으면 일단 캐시에 저장한다.
2.  캐시가 다 찼다면 특정한 방식에 따라 처리한다.
=>  특정한 방식은 CPU마다 다르며, 대표적으로 가장 이전에 쓴(Least Recently Used - LRU) 캐시를 날려버리고 그 자리에 새로운 캐시를 기록하는 방식이 있다.
==>  LRU 방식은 최근에 접근한 데이터를 자주 반복해서 접근한다면 매우 유리하다.

 

  • 컴퓨터의 처리 방식

-  컴파일러는 작성된 코드를 그대로 순서대로 실행하지 않는다.

=>  현대의 CPU는 한 번에 한 명령어씩 실행하는 것이 아니기 때문

 

  • CPU 파이프라이닝(pipelining)

-  한 작업이 끝나기 전에 다른 작업을 시작하는 방식으로 동시에 여러 개의 작업을 실행하는 것을 파이프라인이 이라고 한다.

-  컴파일러는 우리가 최대한 CPU의 파이프라인을 효율적으로 활용할 수 있도록 명령어를 재배치한다.

=>  전제 조건은 명령어를 재배치 하더라도 최종 결과물은 달라지지 않아야 한다.


-  컴파일러가 명령어를 재배치 할 때 다른 쓰레드들을 고려하지 않는다.

  • 수정 순서

-  C++의 모든 객체들은 수정 순서(modification order)라는 것을 정의할 수 있다.

=>  수정 순서란 어떤 객체의 값을 실시간으로 확인할 수 있는 전지전능한 무언가가 잇다고 했을 때, 해당 객체의 값의 변화를 기록한 것


-  모든 쓰레드에서 변수의 수정 순서에 동의만 한다면 문제될 것이 없다.

ex)  같은 시간에 변수 a의 값을 관찰했을 때, 모든 쓰레드들이 동일한 값을 관찰할 필요는 없다.
=>  심지어 동일한 코드를 각기 다른 쓰레드에서 실행하였을 때, 실행하는 순서가 달라도 결과만 같다면 문제되지 않는다.


-  쓰레드 간에서 같은 시간에 변수의 값을 읽었을 때 다른 값을 리턴해도 되는 이유는 CPU 캐시가 각 코어별로 존재하기 때문이다.

=>  각 코어가 각각 자신들의 캐시들을 가진다.

 

  • 원자성(atomicity)

-  C++에서 모든 쓰레드들이 수정 순서에 동의해야만 하는 경우는 바로 모든 연산들이 원자적 일 때 이다.

-  원자적 연산이란, CPU가 명령어 1개로 처리하는 명령으로 중간에 다른 쓰레드가 끼어들 여지가 전혀 없는 연산을 의미한다.

-  원자적인 연산이 아닌 경우 모든 쓰레드에서 같은 수정 순서를 관찰할 수 있음이 보장되지 않으므로 직접 적절한 동기화 방법을 통해서 처리해야 한다.

=>  이를 지키지 않는다면, 프로그램이 정의되지 않은 행동(undefined behavior)을 할 수 있다.


-  원자적 연산들은 올바른 연산을 위해 굳이 뮤텍스가 필요하지 않다.

=>  속도가 더 빠르다.


-  std::atomic<T> 변수;

=>  atomic의 템플릿 인자로 원자적으로 만들고 싶은 타입을 전달한다.


-  CPU는 한 명령어에서 메모리에 읽기 or 쓰기 둘 중 하나밖에 하지 못한다.

-  is_lock_free() 함수는 해당 atomic 객체의 연산들이 정말로 원자적으로 구현될 수 있는지 확인한다.

=>  CPU에 따라 원자적 코드를 생성할 수 없는 경우도 있다.

 

  • memory_order

-  atomic 객체들의 경우 원자적 연산 시에 메모리에 접근할 때 어떠한 방식으로 접근하는지 지정할 수 있다.

  • memory_order_relaxed

-  가장 느슨한 조건으로, 해당 조건으로 메모리에서 읽거나 쓸 경우 주위 다른 메모리 접근들과 순서가 바뀌어도 무방하다.

=>  서로 다른 변수의 relaxed 메모리 연산은 CPU 마음대로 재배치 가능하다.(단일 쓰레드 관점에서 결과가 동일하다면)


-  store, load는 atomic 객체들에 대해 원자적으로 쓰기와 읽기를 지원해주는 함수다.

=>  추가적인 인자로 어떠한 형태로 memory_order을 지정할 것인지 전달할 수 있다.


-  CPU에서 메모리 연산 순서에 관련해서 무한한 자유를 주는 것과 같다.

=>  CPU에서 매우 빠른 속도로 실행할 수 있다.
=>  단, 예상치 못한 결과가 나올 수도 있음.

 

  • memory_order_ acquire 과 memory_order_release

-  memory_order_relaxed가 사용되는 경우도 있지만, CPU에 너무 많은 자유를 부여하기 때문에 사용 용도가 꽤나 제한적이다.

-  생산자 소비자 관계에서는 memory_order_relaxed 를 사용할 수 없다

-  memory_order_release는 해당 명령 이전의 모든 메모리 명령들이 해당 명령 이후로 재배치 되는 것을 금지한다. 또한, 같은 변수를 memory_order_acquire로 읽는 쓰레드가 있다면, memory_order_release 이전에 오는 모든 메모리 명령들이 해당 쓰레드에 의해서 관찰될 수 있어야 한다.

-  두 개의 다른 쓰레드들이 같은 변수의 release와 acquire를 통해서 동기화(synchronize)를 수행한다.

  • memory_order_acq_rel

-  acquire와 release를 모두 수행하는 것

=>  읽기와 쓰기를 모두 수행하는 명령들에 사용된다.
ex)  fetch_add 함수

 

  • memory_order_seq_cst

-  메모리 명령의 순차적 일관성(sequential consistency)을 보장해준다.

=>  순차적 일관성이란 메모리 명령 재배치도 없고 모든 쓰레드에서 모든 시점에 동일한 값을 관찰할 수 있는, 작성된 코드 그대로 CPU가 작동하는 방식이다.


-  atomic 객체를 사용할 때, memory_order를 지정해주지 않는다면 디폴트로 memory_order_seq_cst가 지정된다.

-  멀티 코어 시스템에서 memory_order_seq_cst는 비싼 연산인다.

=>  꼭 필요한 경우에만 사용해야 한다.

 

씹어먹는 C++ - <15 - 3. C++ memory order 와 atomic 객체> (modoocode.com)

 

 

 


 

 

 

틀린 부분이나 이상한 부분이 있으면 댓글로 편하게 지적해주세요!

감사합니다!

 

참고

씹어먹는 C ++ - <15 - 1. 동시에 실행을 시킨다고? - C++ 쓰레드(thread)> (modoocode.com)

<링크 강의 15 - 3까지>

반응형

댓글