Server programming

02_08_멀티쓰레드 프로그래밍_Sleep

devRiripong 2022. 8. 5.
반응형

Q1. context switching는 어떨 때 발생하나오?

Q2. 스케줄링에 대해 설명해 보세요.

Q3. 스레드 상에서 준비, 실행, 대기 되는 프로세스를 유저스페이스와 커널 스페이스와 연관해서 설명해 보세요.

Q4. 일단 자리로 돌아가는 코드를 3가지 버전으로 작성해 보세요.

 

대기를 하는 두번째 방법인 렌덤 메타에 대해 알아볼거야.

지난 시간에 거의 대부분의 기능을 구현해서 오늘은 단순해. 그냥 sleep 계열의 함수하나만 호출하면 끝이야.

sleep 한다는 거 자체가 운영체제의 스케쥴링 관리와 밀접한 관련이 있기 때문에 이거에 관해서 몇가지 언급을 해야 할 거 같아.

두번째 상황 같은 경우는 화장실에 누가 있을 때 서서 무한으로 대기하는게 아니라 일단 자리로 돌아온 다음에 일정시간을 기다렸다 다시 화장실로 가는 그런 케이스를 얘기를 하는거.

운빨에 맡기는거, 자리로 돌아가는 순간에 하필이면 안에서 사람이 나오고 다른 사람이 화장실을 점유한다고 하면 굉장히 슬픈 얘기지만 간발의 차이로 화장실을 소유할 수 있는 기회를 놓치게 된다.

더 언급할 게 있는데 운영체제를 공부할 때 나오는 얘기. 스케쥴링이라는 개념. 반복해서 언급 드리고 있지만 식당이 있으면 직원이 있고, 직원을 움직이기 위해서는 영혼이 빙의 해가지고 직원을 움직인다 했어. 영혼은 CPU 코어, 직원은 스레드 라고 했었어.

 

코어가 하나밖에 없었다고 가정을 해보자. 프로그램이 3개 실행되고 있고, 스레드는 4개가 켜져있는 상태인데, 위의 부분이 유저레벨이고 아래 부분이 커널 영역이라고 얘기를 함.

커널 영역이라고 함은 운영체제를 돌리기 위해서 필요한 중요한 코드들이 다 들어가 있어. 윈도우즈의 핵심적인 프로그램을 실행하기 위해서 커널 모드로 일단 돌아와야 한다. 어떻게 보면 관리자 모드라고 볼 수 있어. 그런데 결국에는 어떤 코드가 실행이 된다는 건 실행하는 주체가 CPU가 되는거야. CPU가 해당 코드를 실행해서 코드가 돌아가야 하는데 아래있는 커널 레벨에 있는 코드들도 유저레벨에 있는 스레드에 빙의해서 돌리는 것과 마찬가지로 CPU가 점유를 해서 돌려야 한다.

이 얘기가 왜 나오냐면 오늘 얘기하고 있는 sleep을 한다는 거 자체가 어떤 의미를 갖고 있는지 부연 설명을 하고자 하는거

커널이 하고 있는 중요한 기능들은 많지만 그 중에서 운영체제가 중요하게 생각해야 될 것이 스케쥴링이란 개념이 있다. 유저 모드에서 실행되고 있는 프로그램이 여러개가 있어. 이 중에서 다음에 누구를 실행해야 될지를 어떤 알고리즘을 통해가지고 골라줘야 된다. 물론 고를 기준은 운영체제마다 정책마다 다른다. 프로그램의 중요도에 따라 가지고 어떤 특정 프로그램에 우선순위를 줄 수도 있을 것이고 그리고 지난 번에 언제 최종적으로 실행 되었는지도 고려를 해서 어느정도 공평하게 시간을 배분을 할 필요가 있을 거야.

 

그리고 만약에 어떤 직원에 빙의를 해서 직원이 움직인다고 가정을 하면 그 상황 자체에서는 커널 코드가 돌아가지 않고 있는 상태가 된다. 그렇다는 것은 결국에는 이 커널 모드에서 다음에 실행되어야 할 애를 골라주기 위해서는 여기 있는 실행권 자체를 다시 커널쪽으로 넘겨 줘야지만 그 코드가 실행이 되어서 다음은 누구누구가 실행이 되어야 겠구나 라는 걸 판단 할 수가 있는거. 결론적으로 스케줄러가 어떤 프로그램을 실행을 할 때 무한적으로 평생 실행해도 된다는 그런 허락을 해주는게 아니라 타임 슬라이스라 해서 너가 몇 초 동안 실행 될 수 있어 어떻게 보면 실행권 같은 걸 준다고 볼 수 있어.

정해준 타임 슬라이스라는 시간 안에서는 실행권을 어느정도 보장을 받는다. 만약 1초 보장 받으면 1초동안 마음이고, 1초 소진하면 자발적으로 실행 소유권을 커널에게 넘겨주게 된다. 그래야지만 이어서 커널코드가 실행이 되면서 커널이 다른 애들을 골라서 실행하는 그런 일련의 부분들이 실행이 된다. 물론 여기있는 타임 슬라이스를 무조건 다 100% 다 소진을 해야 하는 건 아냐. 특정 상황에 의해서 실행권을 다 쓸 필요가 없다고 하면 스스로 반환을 하는 경우도 있을 것이고, 그게 아니라고 한다면 온갖 시스템 콜을 호출했을 때도 자발적으로 자신의 실행권을 일단 1차적으로 반환하는 셈이 된다.

시스템 콜이란

http://www.it.uu.se/education/course/homepage/os/vt18/module-4/implementing-threads/

 

위에가 유저모드, 아래가 커널모드라고 가정을 하고 스레드들이 실행이 되다가 cout같은 콘솔에다가 뭔가 출력을 해주세요라는 걸 실행을 해주면, 이건 유저 레벨에서 하드웨어를 제어하고 이런 개념이 아니기 때문에 무조건 커널모드에다 요청을 해야 한다. 그래서 system call이라고 해서 커널에 요청을 보내는 함수를 실행하면 커널 모드로 돌아와서 커널이 요청받은 걸 실행을 한 다음에 다시 스레드를 재생하는 형태로 동작을 할거야.

이런식으로 어떤 특정 API를 호출할 때 자발적으로 자신의 실행 소유권을 중도 포기하고 이렇게 커널 모드로 돌아오는 경우가 있을 것이고 그게 아니면 우리가 명시적으로 sleep과 같은 일련의 함수를 호출해도 이런식으로 자기 실행권을 알아서 포기하게 된다. 결국에는

이 상황에서 딱 봤을 때 이미 lock을 따른 애가 잡고 있기 때문에 여기서 별다르게 할 수 있는게 없다고 판별이 되면 맨 처음 경우처럼 스핀락을 하며 계속 무한 루프를 돌면서 근성있게 시도하는 방법도 괜찮겠지만 경우에 따라서 깔끔하게 한번에 포기하는 것도 나쁘지 않아. 안에 있는 애가 진짜 안나오겠다 생각이 들면 그냥 내 자리로 돌아 갔다가 나중에 다시 돌아오는 것도 합리적이기 때문에 이럴 때 자기가 처음에 받은 타임 슬라이스를 쿨하게 포기를 하고 커널모드로 돌아가는 것도 하나의 방법일거야.

https://steemit.com/operating-system/@lineplus/2fpjfm-os-arrangement-for-test-process

이 그림은 신규랑, 종료는 일단 무시해도 되고, 어떤 프로그램이 실행이 될 때 프로그램이 실행되는 단위가 스레드니까 스레드라고 생각하면 된다. 이렇게 준비, 실행, 대기 상태 3가지가 있어. 말 그대로 실행하는 상태는 CPU가 빙의해서 스레드를 움직이는 실행 상태를 얘기하고 있는 거고, 경우에 따라 내가 타임 슬라이스를 다 소진 했다. 내가 처음에 보장받은 소유권을 다 실행했다 하면 자발적으로 context switching을 일어나게 해서 준비 상태로 일단은 돌아올 수 있어. 나중에 CPU가 스케줄러를 보고 자기가 정한 알고리즘에 따라 다른 애를 실행 시키건 현재 애를 다시 실행 시키건 어찌됐건 이렇게 이중에서 하나를 골라서 스레드를 하나 실행시키게 될거야. 그걸 준비에서 실행 상태로 넘어가는 상태라고 볼 수 있어. 이게 어떻게 보면 신호등이라 생각하면 된다. 준비는 노란불, 실행은 파란불, 대기는 빨간불이라고 보면 된다.

 

sleep을 하거나 시스템 콜 같은 걸 호출하면 대기 모드로 돌아 왔다가 해당 요청이 끝나면 다시 준비 상태가 되어서 스케쥴링에 의해 다시 실행으로 들어올 수 있다. 이런 식으로 결국 실행, 준비, 대기 상태를 왔다 갔다 하면서 내가 진짜로 실행될 준비가 되었는지 안되었는지 판별을 해서 그거에 따라 스케쥴러가 정해준 다음에 실행 소유권 (1초동안 실행할 수 있는 쿠폰)을 주는거.

 

최종적으로 우리가 하는건 sleep계열의 함수 하나만 호출하면 되는 거지만 사실상 생각보다 복잡한 근본적인 부분들이 개입하고 있는다는 걸 반드시 기억을 해야 한다.

 

서버 프로그래머는 다른 분야와 달리 운영체제나 다른 분야와 엮여 있는 부분이 많아서 실제로 서버 프로그래머 면접을 볼 때는 컴퓨터 전공에 대해 많이 물어본다.

결국에는 실습을 다시 해보러 코드로 돌아갈건데 단순한다.

실행 소유권을 포기한다는 걸 기억 해주면 된다.

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>
#include <atomic>
#include <mutex>

class SpinLock
{
public: 
	void lock()
	{
		// CAS (Compare-And-Swap)

		bool expected = false; 
		bool desired = true; 

		// CAS 의사 코드
	/*	if (_locked == expected)
		{
			expected = _locked; 
			_locked = desired; 
			return true; 
		}
		else
		{
			expected = _locked;
			return false;
		}*/

		while (_locked.compare_exchange_strong(expected, desired) == false)
		{
			expected = false; 
		}

		//while (_locked)
		//{

		//}

		//_locked = true; 
	}

	void unlock()
	{
		// _locked = false;
		_locked.store(false); 
	}

private: 
	atomic<bool> _locked = false; 
};

int32 sum = 0; 
mutex m;
SpinLock spinLock; 

void Add()
{
	for (int32 i = 0; i < 10'0000; i++)
	{
		lock_guard<SpinLock> guard(spinLock); 
		sum++; 
	}
}

void Sub()
{
	for (int32 i = 0; i < 10'0000; i++)
	{
		lock_guard<SpinLock> guard(spinLock);
		sum--;
	}
}

int main()
{
	thread t1(Add); 
	thread t2(Sub);

	t1.join(); 
	t2.join(); 

	cout << sum << endl; 
}

지난 시간에 만든 스핀락을 유심히 살펴 보면

void lock()
	{
		// CAS (Compare-And-Swap)

		bool expected = false; 
		bool desired = true; 

		while (_locked.compare_exchange_strong(expected, desired) == false)
		{
			expected = false; 
		}
	}

lock을 하는 순간 compare and swap 함수를 호출하고 있고 만약에 실패했을 때 어떤 일을 할것인지가 중요한데

여기서는 실패하면 무한루프 돌면서 다시 시도한다.

이런 경우에 어떤 애가 락을 오래 잡고 있는다 하면 CPU점유율이 계속 무한으로 튀면서 잡히게 될거야. 결국에는 얘가 받은 타임 슬라이스라는 자기가 보장받은 실행 시간을 꽉 짜서 끝까지 다 소진하는 상태가 되는거다. 이게 스핀락의 특징. 물론 다른 애가 화장실에 없어서 무한루프가 아니라 바로 성공했다면 얘기가 다르다. 아름다운 상황이지만 최악의 상황은 무한루프 돌면서 CPU를 낭비할 수 있다라는게 스핀락의 개념이었어.

오늘 추가할 거는 여기다가 한줄만 더 추가하면 되는데 옛날에는 Sleep()과 같이 운영체제에 따라 API가 있었어. 근데 이것도 마찬가지로 C++11에서 thread가 추가 되면서 얘네들도 같이 공용 C++ 코드로 관리할 수 있게 되었어.

sleep_for는 언제까지 자라라는 말이니까 여기다가 시간을 넣어 줘서 그 시간 동안 실질적으로 재스케쥴링이 되지 않고 대기를 타다가 그 해당 시간이 끝나면 이제 다시 스케쥴링 대상이 되어서 이 스레드가 다시 실행이 될 수 있다는 얘기가 되는 거.

while (_locked.compare_exchange_strong(expected, desired) == false)
		{
			expected = false; 

			this_thread::sleep_for(std::chrono::milliseconds(100));
		}

이런 식으로 시간을 넣어 줄 수 있다.

예를들어 100 밀리세컨드 동안 잠 잔 다음에 다시 깨어서 이어서 실행을 해라라고 한다면 이런식으로 작성을 할 수 있다.

while (_locked.compare_exchange_strong(expected, desired) == false)
		{
			expected = false; 

			//this_thread::sleep_for(std::chrono::milliseconds(100));
			this_thread::sleep_for(100ms);
		}

이렇게 표현해도 된다.

_NODISCARD constexpr _CHRONO milliseconds operator"" ms(unsigned long long _Val) noexcept /* strengthened */ {
            return _CHRONO milliseconds(_Val);
        }

타고 가서 보면 operator로 정의가 되어서 아까랑 똑같은 표현을 이렇게 할 수 있다는 걸 알 수 있다.

while (_locked.compare_exchange_strong(expected, desired) == false)
		{
			expected = false; 

			//this_thread::sleep_for(std::chrono::milliseconds(100));
			//this_thread::sleep_for(100ms);
			this_thread::yield(); 
		}

yield가 양보한다는 의미가 되는거. 자기가 받은 타임 슬라이스를 양보한 다음에 커널 모드로 돌아가서 알아서 스케쥴링 해라 떠넘기는 거라 볼 수 있어. 사실상

			this_thread::sleep_for(0ms);

랑 개념적으로 똑같다고 보면 된다.

sleep_for는 입력한 시간 동안 재스케쥴링이 되지 않는다는 상황

yield는 언제든지 스케쥴링이 될 수 있지만 기본적으로 현재 타임 슬라이스는 필요 없기 때문에 1차적으로 반환을 해주겠다는 의미가 된다.

두 가지중 하나를 골라서 사용해 주면 된다.

while (_locked.compare_exchange_strong(expected, desired) == false)
		{
			expected = false; 

			//this_thread::sleep_for(std::chrono::milliseconds(100));
			this_thread::sleep_for(0ms);
			//this_thread::yield(); 
		}

이런식으로 sleep_for를 끼어 놓으면 엄연히 말해서 스핀락이라 할 수 없는게 무한으로 while 루프를 돌면서 유저 레벨에서 뺑뺑이 도는게 아니라 하다가 어? 실패했네 하면 일단 커널 모드로 돌아가서 자기가 실행받은 타임슬라이스를 포기하고 있다고 볼 수 있는 거다.

 

이렇게 해서 일단은 lock을 구현을 할 때 spin lock이 아니라 지금 기다리기 싫으면 일단 재자리로 돌아가서 다시 오는 방법을 이렇게 구현할 수 있다.

 

지난번에 얘기한 바와 같이 얘기한 lock에서 누군가가 있을 때 채택할 수 있는 세가지 정책이란 거 자체가 정말로 셋중 하나만 선택해야 한다는게 아니고 이런 식으로 spin lock을 몇 번씩 돌아 보다가, 지금은 사실 한번만 돌고 바로 sleep 하고 있지만 예를 들어 정책을 정해서 5000번 정도는 spin lock 처럼 계속 뺑뺑이를 돌면서 실행하다가 정말 답이 없네 라고 싶으면 그제서야 sleep을 해서 잠시 자기가 받은 타임 슬라이스를 반환 하고 이런 식으로 구현을 할 수 있다.

실행해 보면 마찬가지로 0이 나온다는 걸 알 수 있다.

 

요 방법 자체가 중요한 개념을 갖는 건 아니지만 실습한 이유는 이런식으로 커널 모드로 돌아가는 거 자체가 contex switching을 유발하고 그리고 유저모드에서 커널레벨로 왔다 갔다 하는 그런 부분들이 들어간다고 보면 되기 때문에 나중에 컨텐츠 코드에서 로그를 찍는다고 cout을 남발하면 똑같은 이유로 느려질 수 있다. 그래서 앞으로 코드에서 빠르게 동작해야 되는 부분에선 최대한 이런식으로 system call을 하는 부분들 즉 운영체제한테 뭔가를 요청하는 부분들은 최대한 자제를 해야 한다는 얘기가 된다.

 

이렇게 해서 두번째 방법 실습해 봤고, 다음시간에 Event 방법 실습 해볼거야.

반응형

댓글