TIL/OS

4. Thread

han1693516 2023. 7. 25. 13:38

Thread : CPU에서 업무를 수행하는 가장 작은 단위

 

      우리는 지금까지 스레드 없이 프로세스에 대해서만 설명을 이어나갔습니다.
      프로세스에게는 Code, Data, Stack으로 이루어진 PCB가 있고, fork()를 이용해 같은 내용의
      PCB를 가지는 자식 프로세스를 만들고, exec()를 이용해 PCB의 내용을 바꾸어갔습니다.

 

      하지만, 프로세스를 사용하면서 개발자들은 "프로세스는 비용을 많이 잡아먹는다!" 라고 느끼게 됩니다.
      그 이유는 만약 같은 일을 하는 프로세스를 여러 개 열어야 하는 경우, 그 만큼의 PCB가 필요하고, 
      이는 메인 메모리 공간의 낭비로 이어집니다. 그리고 이를 위해서는 큰 용량을 가진 PCB의 복제가 필요하고,
      이는 시간의 낭비로도 이어지게 됩니다.  그렇기 때문에, 개발자들은 프로세스를 쪼갭니다. 
      이것이 Thread의 시작입니다.

 

      CF> 코어(Core) : CPU에서 연산 작업을 수행하는 부분입니다.

                                  보통 CPU 내 코어에서는 하나의 스레드만 실행 가능합니다.

                                  즉, CPU 안에 코어가 4개면 한 번에 4개의 스레드를 동시에(Parallel) 실행할 수 있습니다.

                                  앞에서 프로세스에 대해 설명할 때, 한 번에 하나의 프로세스만 실행할 수 있다고 했는데,

                                  이는 CPU의 코어가 오직 1개만 있고, 프로세스는 하나의 스레드로만 이루어진 프로세스일 때입니다.

<그림 1> Thread 1, 2가 있는 Process A의 PCB

                       프로세스를 여러 스레드로 나눈 것은 좋습니다. 

                       그렇다면, 각 스레드가 공유해도 되는 것은 무엇이고, 각자 고유의 값을 가져야 하는 건 무엇일까요?
                       Stack, PC, Register는 스레드마다 가지게 되고, 나머지 Code, data는 공유하게 

                       됩니다. Code의 경우, 이는 실행이 일어나면 바뀌지 않으므로 공유해도 크게 상관없을 것이고, Data의 경우
                       전역변수 등이 저장되어 있으므로 공유가 일어나야 되겠습니다. 하지만 PC, Register, Stack의 경우에는

                       각 스레드마다 고유의 값을 가져야 되겠지요. 각 스레드마다 같은 프로세스의 코드를 실행시키고 있는데,

                       만약 Thread 1의 PC 값을 Thread 2가 사용하고, Thread 2의 Stack 값을 Thread 1이 사용할 경우, 

                       원하는 결과가 나오지 않을 것입니다. 즉, 우리는 프로세스는 단순히 스레드를 위한 컨테이너에 불과하고,                           여기서 우리는 실제 Context Switch의 주체가 Thread라는 것을 알 수 있습니다. 왜냐하면 앞서 언급한                                 Context Switch는 현재 실행 중인 프로세스의 Register, PC를 저장하고 다음 실행할 예정인 프로세스의                               Register, PC를 로드하는 것인데, 앞에서 언급했다시피 프로세스는 단순히 컨테이너에 불과하고 실제로

                       업무를 수행하는 단위는 스레드이기 때문이죠. 코어의 레지스터, PC의 값도 코어에 올려져 있는 프로세스                            의 것이 아닌 프로세스에 속한 스레드의 것이기 때문이에요.
                       (PC : Program Counter, 다음 실행할 명령어의 주소 저장, Stack : 지역변수 등 저장)

                       그렇다면, 프로세스를 여러 개의 스레드로 속한 장점이 무엇이 있는지 정리해보도록 하죠
                            1) Resource Sharing : 스레드는 같은 프로세스에 속한 자원을 공유하기 때문에,

                                                                서로 다른 프로세스끼리 공유하는 것보다 간단합니다.

 

                             2) Lighter Weight : 앞서 설명했습니다. fork()를 통해 새로운 프로세스를 만들 때,

                                                            우리는 부모 프로세스의 PCB의 Stack, Code, Data 등 내부의 모든

                                                            요소를 복제해야 하지만, 스레드의 경우 속한 프로세스의 Code와 Data

                                                            는 공유하므로, 만들어질 스레드를 위한 Stack만 만들어주면 되므로 보다 시간,

                                                            공간을 절약할 수 있게 됩니다. 

                                                                cf> TCB (Thread Control Block) : PCB의 개념이 그대로 스레드에 적용된

                                                                      것이라 보시면 됩니다. 스레드의 Register, PC의 값이 저장되며, 

                                                                      이러한 TCB의 리스트는 속한 PCB에 저장되어 있습니다.

                                                                           ex) 위 사진을 예로 들면, Process A의 PCB에 Thread 1, 2의 TCB가

                                                                                 Linked List의 형태로 저장되어 있다고 보시면 될 거 같습니다.

 

                                                                  cf2> Thread의 경우, pthread_create()를 통해 만들 수 있습니다.

 

                             3) Non-blocking System call : 하나의 스레드의 상태가 Waiting 상태가 되어도,

                                                                             전체 프로세스가 멈추지 않습니다!

                                                                             과거 Process State에 대해 설명했을 때, 프로세스가 Syscall을 실행시킬                                                                               경우, Trap이 발생하므로 프로세스는 Waiting 상태로 돌아가므로, 실행이

                                                                             불가하다고 배웠습니다. 하지만 프로세스가 멀티 스레드로 이루어져 있을

                                                                             경우, 하나의 스레드가 Waiting 상태가 되어도, 나머지 스레드는 계속

                                                                             Running 상태로 있을 수 있습니다. 

                                         

<그림 2> Concurrent vs Parallelism

                 

                Concurrency :  하나의 코어가 여러 스레드를 빠른 속도로 번갈아가며 실행시키는 것을 의미합니다.   
                                        과거 프로세스에 대해 배울 때 언급됐던 방식으로, 하나의 프로세스가 하나의 스레드만

                                        가지고 있고, 코어 역시 하나만 있을 경우 적용할 수 있습니다. 

                                  ex) CPU에 하나의 코어만 있고, 각 프로세스는 하나의 스레드로 이루어져 있을 때
                                        T1(P1) -> T2(P2) -> T3(P3) -> T1(P1) -> T2(P2) -> .... 순 실행 

                                            ---> 하나의 코어에서 인터럽트 발생하면 실행되는 프로세스 교체됨
                 

                   Parallelism : 다중 코어  환경에서 가능하고, 여러 코어가 여러 스레드를 동시에 실행시킬 때를 의미합니다.

                                        물론, Parallelism 내부의 각 코어에서도 Concurrency를 적용시키면서 실행이 이루어집니다.   


                                    ex) CPU가 2개의 코어로 이루어져 있고, 프로세스가 4개의 스레드로 이루어져 있을 시
                                           Core 1 : T1(P1) -> T3(P1) -> T1(P1) -> T3(P1) -> ...

                                           Core 2 : T2(P1) -> T4(P1) -> T2(P1) -> T4(P1) -> ...

                                               ---> 하나의 코어에서 인터럽트 발생해도 P1은 계속 실행됨

           

           위와 같은 Multithreading을 구현하기 위한 모델은 Kernel threads, User threads 크게 두 가지가 있습니다.
           Kernel threads의 경우, 쉽게 말하면 Multithreading, 프로세스가 여러 개의 스레드로 이루어져 있다는 것을

           Kernel(OS)가 알고 있는 형태입니다. 그렇기 때문에 각 PCB에 TCB의 리스트가 있는 등 OS의 서포트를 받을 
           수 있는 것입니다. 하지만 위 형태의 경우, thread를 만들기 위해서는 반드시 syscall을 거쳐야만 thread를 만들

           수 있으므로 아래 얘기할 User threads에 비해 무겁다는 단점이 있습니다.

           User threads는 반대로 OS는 프로세스가 여러 개의 스레드로 이루어져 있는 걸 모르는 상태입니다.                                     위 형태의 특징은 syscall 없이 단순히 라이브러리를 이용해 thread를 만드는 형태입니다. syscall을 거치지 않으므               로 Kernel threads보다 thread 관련 동작이 빠르지만, OS는 하나의 프로세스가 여러 개의 thread로 이루어져 있다는

           사실을 모르므로, 하나의 스레드에서 syscall 발생 시, 그 스레드가 속한 프로세스 전체가 멈춰버리는 단점이 있습               니다.

                               

           하지만, 이러한 스레드를 사용함으로써, Code와 Data를 공유함으로써 발생할 수 있는 문제가 있습니다. 바로 Race             Condition이라는 것인데요. 
         

                                                                                

 

위 코드는, x에 1을 더한 뒤, 그 값을 출력하는 역할을 하는 Shared Code를 fork version에서는 fork()를 이용해 기능은 같지만 기존 프로세스의 데이터를 공유하지 않는 새로운 프로세스를 만드는 과정이고, threads version에서는 pthread_create()를 이용해 func 함수의 기능을 가지면서, 데이터 역시 공유하는 새로운 스레드를 만드는 과정입니다.

 

            - Shared code : int 전역변수 x =1과 x에 1을 더한 후 출력하는 func() 함수 포함

            - fork version : fork() 통해 새로운 프로세스 만든 뒤, func() 실행 (memory address space의 code, data 공유 X)                    - threads version : pthread_create() 통해 새로운 스레드 만든 뒤, func() 실행 (memory address space의 code,data                                            공유 O)

 

fork version을 적용시킬 경우, 결과가 어떻게 나올지 살펴보겠습니다.

 

fork version : 부모 프로세스 먼저 실행될 경우
fork version 자식 프로세스 먼저 실행될 경우

fork()를 이용해 새로운 프로세스를 만들 경우, 출력되는 x의 값은 실행되는 프로세스의 순서(부모 -> 자식 or 자식 -> 부모)나 인터럽트의 실행 유무에 관계없이 2 2가 출력될 것입니다. 왜냐하면 부모, 자식 프로세스는 각각 독립된 memory address space를 가지므로, 다른 프로세스의 변수 x의 값이 어떻게 변하든 자신 프로세스의 변수 x의 값은 전혀 영향을 받지 않는다는 거죠. 처음에는 이 말이 잘 이해가 안 되실 수도 있지만, threads version을 보시면 "영향을 받는다." 의 의미를 보다 잘 이해할 수 있으실 겁니다.

 

threads version : 부모 스레드 먼저 실행될 경우 (2, 3 순 출력)

 위 경우에는 x가 어떻게 출력될까요? 앞서 fork version에서는 2 2가 출력되었습니다. 그 이유는 fork()를 통해 만들어진       프로세스의 경우 각각 독립된 memory address space를 가지고 있기 때문에, 한 프로세스의 x의 값이 어떻게 변하든지, 다른 프로세스의 x에는 영향을 끼치지 않기 때문이죠. 하지만 위 threads version의 Parent thread와 Child thread는 전부 같은 프로세스에 속하고 있고, 변수 x는 전역변수이기 때문에 Parent thread에서 x의 값을 바꿀 경우, Child thread에서도 그 바뀐 x의 값을 이용해 연산이 일어나게 됩니다. 왜냐하면 전역변수는 memory address space의 data에 속하고, 같은 프로세스에 속한 스레드들은 code, data를 공유하기 때문이죠. 

 

그렇기 때문에, 부모 스레드의 func가 종료된 이후, x의 값은 2로 바뀌고 x의 값인 2가 출력될 것입니다. 그 이후, child thread에서는 x의 값은 2+1=3으로 바뀌고, x의 값인 3이 출력될 것입니다.

 

threads version : 부모 스레드 x=x+1 실행 이후 인터럽트 발생 (3, 3 순 출력)

그렇다면 위 경우는 어떨까요? 슬라이드를 보시면 Parent thread에서 x=x+1 실행 이후, 인터럽트가 발생해 child thread가 실행된 이후, 다시 parent thread에서의 printf가 실행되는 것을 볼 수 있습니다. 

 

우선, Parent thread에서 x=x+1이 실행되었으므로 전역변수 x의 값은 2입니다. 이후 인터럽트가 발생해 child thread가 실행됩니다.

 

두 번째로, child thread에서 x=x+1이 실행되고, printf가 실행되므로 전역변수 x의 값은 3으로 바뀌고 3이 출력됩니다.

 

세 번째로, child thread가 종료되었으므로 다시 parent thread가 실행되고, parent thread의 printf에서 전역변수 x의 값을 출력하므로 3이 출력될 것입니다. 

 

threads version : 부모 스레드 printf 실행 중 인터럽트 발생 (3, 2 순 출력)

위 경우에는 어떻게 될까요? 위 상황은 부모 스레드의 printf 실행 중 (printf를 이루고 있는 어떤 명령어 실행 이후) 인터럽트가 발생해 child thread가 실행되고, child thread가 종료된 이후 printf의 나머지 명령어가 실행되는 상황입니다. 위 상황에서printf() 함수는 두 개의 명령어로 이루어진 함수라고 가정하겠습니다. 인자로 받은 문자열을 buffer에 저장시키는 명령어, 이후 buffer에 저장된 문자열의 값을 출력하는 명령어로 말이죠. 앞서 인터럽트에서도 말했듯이, CPU는 명령어 실행 이후, Interrupt Line이 set되었는지 확인한 뒤, 만약 set되어 있다면 interrupt service routine을 실행시키는 방식이었죠. 위 상황도 마찬가지입니다. buffer에 문자열을 저장시키는 명령어를 실행시킨 이후 인터럽트가 발생한 상황입니다

 

우선, Parent thread에서 x=x+1이 실행되었으므로 전역변수 x의 값은 2입니다. 

 

두 번째로, printf()의 인자(문자열)를 buffer에 저장시킨 명령어 실행 후, 인터럽트가 발생합니다. (따라서, Parent thread의 buffer의 x의 값은 2가 저장되어 있을 겁니다)

 

세 번째로, child thread가 실행됩니다. x의 값은 3으로 바뀌고, x의 값인 3이 출력되겠네요.

 

네 번째로, parent thread, printf()의 두 번째 명령어인 buffer에 저장된 문자열의 값을 출력하는 명령어가 실행됩니다. 두 번째 단계에서 buffer에 저장시킨 x의 값은 2이므로, 2가 출력될 것입니다. 

threads version : 부모 스레드 x = x+1 실행 중 인터럽트 발생 (2, 2 순 출력)

위 상황은 어떨까요? 위 상황은 부모 스레드의 x = x+1 실행 중 인터럽트가 발생한 경우입니다. x=x+1은 세 가지 명령어로 

나눌 수 있습니다. 메모리에서 x의 값을 가져오는 명령어, x+1을 실행시킨 후, 그 연산(x+1)의 결과를 레지스터에 저장하는 명령어, 두 번째 명령어의 값을 다시 메모리에 저장시키는 명령어로 말이죠. 위 상황에서는 연산 결과를 레지스터에 저장하는 명령어 실행 후 (즉, 아직 메모리에 변화된 x의 값은 저장되지 않았으므로 현재 x의 값은 아직 1입니다.), 인터럽트가 발생한 경우입니다.

 

우선, Parent thread에서 x=x+1의 명령어 두 개가 실행됩니다. 변수 x의 값을 가져온 후, x+1의 값을 레지스터에 저장시키는 명령어입니다. 따라서 메모리의 x의 값은 1, parent 스레드의 레지스터에서의 x의 값은 2입니다. 이후 인터럽트가 발생합니다.

 

두 번째로, child thread가 실행됩니다. x의 값은 2로 바뀌고, x의 값인 2가 출력되겠네요. (아직 메모리에 x의 값은 반영시키지 않았으므로, child thread가 메모리에 가져온 x의 값은 1입니다)

 

세 번째로, child thread가 종료되었으므로 parent thread의 나머지 코드가 실행됩니다. 레지스터에 있는 x의 값은 2를 메모리의 x에 저장시킨 뒤, 출력시킵니다. 하지만 child thread에서 이미 x의 값을 2로 바꿨으므로 x의 값에 변화는 없습니다. 따라서 2가 출력됩니다.

 

 

차이점이 보이시나요? 프로세스를 이용했을 때에는 각각 서로 다른 전역변수 x를 사용하므로 출력되는 값이 2 2로 일정하지만, 스레드를 이용했을 때에는 (2 3), (3 3), (3 2), (2 2)로 다른 결과가 나올 수 있음을 보여주고 있습니다. 이렇게 같은 코드를 실행시켜도 다른 결과가 나오는 상황을 Race Condition이라고 합니다. 하지만 위처럼 결과가 일정하지 않은 상황이 사용자 입장에서 바람직한 상황은 아니겠죠? 따라서 위 Race Condition을 막기 위해 사람들은 Synchronization(동기화)를 고안하게 됩니다. 위 내용은 나중에 작성하겠습니다. 다음 글은 Scheduling 관련해서 작성해 보겠습니다. 감사합니다

'TIL > OS' 카테고리의 다른 글

5. Scheduling (FCFS, SJF, RR, Priority Scheduling)  (1) 2023.08.10
3. Process  (0) 2023.05.05