티스토리 뷰

스레드의 동작 원리

멀티스레드, 싱글스레드라고 부르며 작업을 동시에 처리하는 기술이라고만 알고 있는 스레드에 대해 조금 더 알아보자.

 

우선 단일 코어 CPU가 달린 컴퓨터가 있다고 생각하자. 이런 환경에서 프로세스나 여러 스레드가 동시에 실행될 수 있을까? 만약 A 작업과 B 작업을 동시에 진행시킨다면 어떻게 처리할까.

 

우선 사람이라면 보통 작업의 순서가 정해져있지 않는 이상 하나의 작업을 완료하고 다른 작업을 진행할 것이다. 하지만 우리가 다루는 컴퓨터는 사람과 동일하게 생각하지 않는다.

 

컴퓨터는 하나의 작업을 하면서 여유가 생기는 찰나의 순간 다른 작업을 진행하려 한다. 즉, 여러 프로세스와 스레드들을 동시에 실행하는 운영체제는 일정 시간마다 번갈아가며 스레드들을 실행한다. 이렇게 스레드를 변경하며 실행하는 과정컨텍스트 스위치(context switch)라고 한다.

 

 


컨텍스트 스위치

컨텍스트 스위치는 다수의 스레드들을 돌아가며 실행시키기 위해서 생기는 변경 과정을 말한다고 했다. 또한 컨텍스트 스위치는 컴퓨터 입장에서는 적지 않은 양의 연산이 필요하다.

 

  1. 실행 중이던 스레드 blocked상태로 전환하고 내부 데이터와 함께 어딘가에 저장한다.
  2. 과거에 실행하던 blocked 스레드 중 하나를 선택한다.
  3. 선택한 스레드의 상태(=호출 스택 등)를 복원한다.
  4. 실행하던 지점으로 강제 이동하여 작업을 이어한다.

 

위에 나열한 과정만보아도 적지않은 연산이 들어간다는 것을 알 수 있다. 동시에 실행하는 스레드가 여럿이라 하더라도 컨텍스트 스위치가 지나치게 자주 일어난다면, 스레드에 할애되는 시간보다 스레드 교체 시간이 더 많아져 비효율적이게 된다.

 

 

 

그렇다면 컨텍스트 스위치 횟수를 최대한 줄이면 되지 않을까? 이 또한 문제가 발생한다.

 

사용자 눈에 보이는 애니메이션 렌더링 작업 스레드와 백그라운드 스레드를 1초 간격으로 실행한다면 충분히 긴 시간이 주어진 것이지만 사용자의 눈에 1초마다 애니메이션이 실행되어 답답해 보일 수 있다.

 

그렇다. 결국 컨텍스트 스위치 실행은 기본적으로 '사용자의 쾌적한 사용감'을 보장할 수 있는 가장 긴 시간 단위가 필요한 것이다. 이 시간 단위를 타임 슬라이스(time slice)라고 한다.

 

운영체제나 CPU 같은 환경이 다르지만, 보통 스레드 하나를 정지(blocked)하고 다시 실행(runnable)하는데, 약 5밀리초가 필요하다. 컴퓨터 입장에서 초당 1억 개의 명령어 처리가 가능한 CPU에게 5밀리초라면, $100,000,000 * 0.005 = 500,000$개의 명령어를 처리할 시간이다. 이렇게 보면 짧은 시간은 아니다.

 

 

멀티 코어 CPU

CPU가 2개이고 각 CPU가 2개의 코어를 가진다면 컨텍스트 스위치로 발생하는 문제점을 보안할 수 있을까? 이 질문에 답변을 스스로 생각하기 위해서는 CPU 개수와 스레드 개수의 관계에 대해 알아야 한다.

 

CPU 개수와 스레드 개수가 같거나 스래드 개수가 더 적은 경우 컨텍스트 스위치가 발생할 이유가 없다.(외부적 요인으로 발생할 수도 있기는 하다.) 하지만 스레드 개수가 더 많다면 컨텍스트 스위치가 CPU 안에서 발생하게 된다. 특히 실행(runnable) 상태의 스레드가 CPU 개수보다 많을 경우 성능의 문제로 직결되어 컴퓨터가 느려진다.

 

 

컨텍스트 스위치 수행 시점

또다른 주의점으로 프로그램이 어디까지 실행된 후 컨텍스트 스위치가 일어나는지 알아야한다.

 

컴퓨터는 기계어 명령어 단위로 명령어를 실행한다. 컨텍스트 스위치도 이와 마찬가지로 기계어 명령어 단위로 일어난다. 다시 말하면 기계어 명령어 수행 도중에는 컨텍스트 스위치가 일어나지 않는다.

 

간단한 연산 처리
a = b + 2;

 

위 코드에서 a에게 $b + 2$연산을 진행한다. 이러한 경우 실제 기계어는 다음과 같이 동작한다.

 

  1. 연산 대기
  2. $r1 = b$
  3. $r2 = r1 + 2$
  4. $a = r2$
  5. 연산 종료

※ r1, r2 = 레지스터에 등록되는 공간

※ a, b = 메모리에 등록되는 공간

 

위 5단계 기계어 명령어 중 어떠한 순서에서든 컨텍스트 스위치가 발생할 수 있다. 이러한 과정은 너무나도 뻔한 단순 연산과정마저 예상치 못하게 만들 수 있다.

 

 


스레드 사용 주의 사항

여러 스레드가 같은 값을 동시에 접근하는 경우를 경쟁 상태 혹은 데이터 레이스(data race)라고 한다. 정확히는 다수의 스레드가 값 하나를 번갈아 가며 접근하는 것이며, 특별한 조치를 하지 않는다면 두 스레드가 접근하는 값이 어떻게 변경될지 아무도 모르게되는 이상한 결과 값이 출력될 수 있다.

 

데이터 레이스 예시
x += y;
기계어 명령어 순서
  1. 연산 입장
  2. $r1 = x$
  3. $r2 = y$
  4. $r3 = r1 + r2$
  5. $x = r3$
  6. 연산 종료

위 연산을 처리하기 위해서 총 6가지 동작이 이루어진다. 그러면 다음과 같은 코드가 있다면 어떤 결과가 나올까?

 

전역 변수 공유 상황

※ 싱글코어 기준의 예시

#include <iostream>
#include <thread>

using namespace std;

int result = 0; // 전역 변수

void Add() 
{
    cout << "Add" << endl;
    for (int k = 0; k < 1000000; k++)
    {
        result += 1;
    }
}
void TakeAway() 
{ 
    cout << "TakeAway" << endl;
    for (int k = 0; k < 1000000; k++)
    {
        result -= 1;
    }
}

int main() {
    thread add(Add);
    thread takeAway(TakeAway);
    
    // 스레드들이 종료될 때까지 대기
    add.join();
    takeAway.join();
    
    cout << result << endl;

    return 0;
}
결과들
AddTakeAway

672102
Add
TakeAway
0
TakeAway
Add
66484

 

 

위 코드는 단순히 result라는 전역변수에 1씩 100만번의 덧셈과 1씩 100만번의 뺄셈을 하는 코드이다. 각 덧셈과 뺄셈은 스레드를 통해 진행되며 두 스레드는 하나의 메인스레드에서 선언된다. 그런데 매번 코드를 실행할 때마다 다른 결과가 나타난다.

 

논리적으로 생각했을때, 동일하게 1을 100만번을 더하고 빼면 0이 되어야 한다. 그런데 코드 결과는 가끔 다른 결과가 나온다. 심지어 가장 처음 작성했던 문자열 출력(cout)구문마저도 뭔가 이상하게 꼬여버렸다.

 

이러한 현상이 바로 기계어 명령어 단위에서 서로 작업이 얽혀서 실행되어 발생하는 현상이다. 앞서 멀티스레딩에 대해 학습하던 장에서 우리는 언제 컨텍스트 스위칭이 정확히 언제 일어날지 모른다고 배웠다. 그러므로 우선 Add와 TakeAway함수의 문자열을 출력하는 구문부터 cout과 << 연산자를 거치며 기계어 명령어의 단계에서 잠시 대기하는 순간이 발생하면 다른 스레드로 컨텍스트 스위칭을 한다.

 

더보기
어셈블리 언어 문자열 출력
	JMP Start		; Start 레이블로 점프하여 데이터 테이블을 건너뛴다

	DB  "Add"		; 문자열 "Add"를 데이터로 정의
	DB   00			; 문자열의 끝을 나타내는 널 문자 (null terminator)

Start:
	MOV  AL,  C0	; AL 레지스터에 C0(주소)를 설정하여 비디오 RAM을 가리킴
	MOV  BL,  02	; BL 레지스터에 02를 설정하여 문자열의 첫 번째 문자를 가리킴
	MOV  CL, [BL]	; CL 레지스터에 BL이 가리키는 메모리 위치의 데이터를 복사

Loop:
	MOV [AL], CL	; AL이 가리키는 비디오 RAM 위치에 CL의 데이터를 복사
	INC  AL			; 다음 비디오 RAM 위치를 가리키도록 AL을 증가
	INC  BL			; 다음 문자를 가리키도록 BL을 증가
	MOV  CL, [BL]	; 새로운 문자의 데이터를 CL에 복사

	CMP  CL,  00	; CL의 값이 널 문자인지 확인하여 문자열의 끝인지 검사
	JNZ Loop		; 널 문자가 아니면 Loop로 점프하여 다음 문자 처리

 

위는 어셈블리 언어에서 "Add"라는 문자열을 출력하기 위해 실행되는 어셈블리 명령어들이다. 앞서 배운바를 적용하면 기계어 명령어 사이에 컨텍스트 스위칭이 이루어질 수 있기에, 문자를 출력하는 명령어를 처리하고 나서 출력되는 위치에서 컨텍스트 스위칭이 발생할 수 있는 것이다.

 

명령어 설명
JMP Jump, 프로그램의 실행 흐름을 주어진 주소로 이동
DB Define Byte, 메모리에 바이트 값 정의
MOV Move, 레지스터나 메모리 간 데이터 이동
INC Increment, 레지스터나 메모리 값에 1 증가
DEC Decrement, 레지스터나 메모리 값에 1 감소
CMP Compare, 두 값 비교 및 플래그 레지스터 설정
JNZ Jump if Not Zerom, 플래그 레지스터가 "Zero" 플래그가 아닌 경우 주어진 주소로 이

 

 

위 결과 중 문자열 출력 제멋대로인 특이한 결과도 문자열 출력 연산 순서가 무작위로 컨텍스트 스위칭되어 버린 것이다.

이는 산술 연산자에도 동일하게 적용된다.

 

 

일반 산술 연산도 같은 값을 두 번 연산하거나 결과값 적용을 건너뛰는 등의 여러 문제가 발생할 수 있다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함