본문 바로가기

OS

Thread & Process

우리는 스레드와 프로세스에 대해서 한번쯤은 들어봤을것이다. 하지만 막상 스레드가 무엇이고 프로세스가 무엇인지 누군가 물어본다면 정확한 개념을 이해하고 있지 않기 때문에 설명하기 막막할 것이다. 본문을 통해 스레드와 프로세스에 대해서 이해해보도록 하자.


Process?

우리는 작업관리자를 키면 실행중인 프로세스가 어떠한 것들이 있는지 알 수 있다. 이를 통해 알 수 있듯이, 프로세스란 운영체제로 부터 자원을 할당받는 작업의 단위이다. 

그렇다면 운영체제로 부터 어떤 자원을 할당 받는지 다음의 그림을 통해 보도록 하자.

그림을 보면 알 수 있듯이 하나의 프로세스는 코드, 데이터, 스택, 힙 이라는 자원을 운영체제로 부터 할당받는다. 각각 무슨 역할을 하는지 이름만 보고도 추론할 수 있는데 이를 간단하게 살펴보자면,

 

코드

이름과 같이 프로그램의 코드가 저장되는 영역이다.

 

데이터

코드가 실행되면서 코드에서 선언한 전역변수, static변수가 저장되어 있는 영역이다.

 

스택

스택 영역은 함수 안에서 선언된 지역변수, 매개변수, 리턴값, 돌아올 주소 등등이 저장되는 영역으로 함수 호출시 기

록하고 종료되면 제거한다. 이때 스택영역은 컴파일시 크기가 결정되며, 재귀호출을 너무 깊게 하거나 지역변수를 너무 많이 갖고 있으면 stack overflow오류가 발생한다.

 

힙 영역은 런타임 시점에 크기가 정해지는 영역으로, 프로그래머가 동적으로 할당과 해제를 할 수 있는 메모리 영역이다. 힙 영역에는 동적으로 할당한 데이터가 들어가 있으며, 자바에서는 가비지 컬렉터라는 것이 이 힙 영역을 알아서 관리해준다.

 

이렇듯 프로세스는 운영체제로부터 위와 같은 자원들을 할당받아 실행되는 작업의 단위이다.

 

Thread?

위에서 프로세스에 대해서 설명했다 그렇다면 스레드란 무엇일까? 

스레드란 프로세스 내에서 실행되는 흐름의 단위이다. 즉, 프로세스 내에서의 분기처리를 의미한다. 좀 더 직관적으로 이해하기 위해 다음 그림을 보자.

 

위의 그림을 보면 알 수 있듯이 하나의 프로세스 내에는 최소한 1개 이상의 스레드가 존재한다. 이때 스레드끼리는 서로 자원을 공유하는데, 다음 그림 영역과 같이 stack영역을 제외하고 공유한다.

위에서 설명한 바와 같이 stack영역에는 함수와 관련된것들이 저장되어 있는데 stack영역이 분리되어 있다는 것은 독립적인 함수 실행이 가능하다는 의미이다. 따라서 stack영역이 분리되어 있다는 것은 2개 이상의 스레드를 실행할 수 있다는 최소한의 조건인 셈이다.

 

Multi-Thread?

이때 하나의 프로세스에 2개 이상의 스레드가 존재하는것을 멀티 스레드라고 하는데, 멀티스레드를 이용했을 경우 예를들어 웹브라우저에서 하나의 스레드가 파일을 업로드 하는동안 다른 스레드는 사용자와 상호작용하는 일을 할 수 있다. 하지만 우리는 일반적으로 코어가 하나일때 컴퓨터는 한번에 한가지의 일만 가능하다는것으로 알고 있다. 그렇다면 한번에 여러가지 일을 하는것처럼 보이는 이 멀티 스레드는 어떻게 작동할까?

 

Context-Swtiching 

바로 스레드간 context-switching을 하며 작업을 하기 때문에 동시에 하는것처럼 보이는 것이다. context-switching이란 하고있는 작업을 중단하고 다른 작업을 해야 할때 작업을 교체하는 것을 말한다. 이러한 하나의 코어에서 여러개의 스레드가 존재해 작업을 동시에 하는것처럼 보이는것을 동시성 이라고 하며, 두개 이상의 코어에서 여러 스레드가 실제로 동시에 작업을 하는것을 병렬성 이라고 한다. 스레드끼리는 많은 자원을 공유하기 때문에 context-switching을 하는데 발생하는 비용은 그렇게 크지는 않다. 이처럼 완벽해 보이는 멀티 스레드는 다음과 같은 단점이 있다.

 

단점

우선, 공유하는 자원에 여러 스레드가 동시에 접근할 경우 예상하지 못한 결과를 낳을 수 있다. 너무 추상적이니 다음의 코드를 살펴보자.

public class Main {

    public static void main(String[] args) {
        Account account = new Account();

        for (int i = 0; i < 10; i++) {
            Thread deposit = new Thread(new DepositThread(account));
            Thread withdraw = new Thread(new WithdrawThread(account));
            deposit.start();
            withdraw.start();
        }
    }

}

class Account {
    static int money = 10000;

    public void deposit(int amount) {
        money += amount;
    }

    public void withdraw(int amount) {
        money -= amount;
    }

    public void print() {
        System.out.println("현재 남은 잔액은 " + money + "원 입니다.");
    }
}

class DepositThread implements Runnable {
    Account account;

    DepositThread(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            account.deposit(10000);
        }
        account.print();
    }
}

class WithdrawThread implements Runnable {
    Account account;

    WithdrawThread(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            account.withdraw(10000);
        }
        account.print();
    }
}

코드를 설명하자면 Account클래스에 있는 static 변수인 money에 여러 스레드가 접근해 하나의 스레드당 10번씩 만원을 넣고 빼는 과정을 반복하는 코드이다. 스레드가 서로 무작위로 작업을 번갈아가면서 한다는것을 감안 했을때 우리는 결과적으로 마지막 남은 스레드는 앞의 스레드가 399번의 10000원을 넣었다 뺐다 작업을 했으므로 결국 마지막에는 10000원이 남는다는 결과를 예측할 수 있다.

 

하지만 예상과 다르게 결과는 다음과 같다.

여러번의 작업을 해도 결과는 만원이 나올때도 있고 아닐때도 있었다. 이러한 상황을 방지하기 위해 우리는 동기화 작업을 해줘야 한다. 위의 코드를 다음과 같이 수정해보자.

class Account {
    static int money = 10000;

    public synchronized void deposit(int amount) {
        money += amount;
    }

    public synchronized void withdraw(int amount) {
        money -= amount;
    }

    public synchronized void print() {
        System.out.println("현재 남은 잔액은 " + money + "원 입니다.");
    }
}

메소드 앞에 synchronized예약어를 붙여 해당 메소드를 수행할 때 한번에 하나의 스레드에서만 작업할 수 있도록 해주면 된다. 또는 다음과 같이 sychronized()블록을 이용해 해당 작업을 할 때 스레드에 안전한 작업을 할 수 있도록 설정할 수 있다.

class DepositThread implements Runnable {
    Account account;

    DepositThread(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        synchronized (account) {
            for (int i = 0; i < 10; i++) {
                account.deposit(10000);
            }
        }
        account.print();
    }
}

class WithdrawThread implements Runnable {
    Account account;

    WithdrawThread(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        synchronized (account) {
            for (int i = 0; i < 10; i++) {
                account.withdraw(10000);
            }
        }
        account.print();
    }
}

결과는 예상대로 몇번의 작업을 하던 결국에 마지막에 출력되는 값은 다음과 같이 10000원이다.

 

  • 다음의 단점으로는 스레드를 무분별하게 사용할 경우 context-switching으로 인한 오버헤드가 발생해 오히려 프로그램이 더 무거워 질 수 있다.
  • 또한 스레드 간에는 자원을 공유하기 때문에 하나의 스레드에서 문제가 생겼을 경우 다른 스레드에도 영향을 줄 수 있고, 전체 프로세스에 영향을 끼칠 수 있다.
  • 그리고 위에서 공유 자원에 여러가지 스레드가 접근하여 예상치 못한 결과를 초래하는것을 예방하기 위해 동기화를 사용한다고 했는데, 동기화를 사용함으로써 생기는 문제 또한 있다. 이는 다음 글에서 설명하겠다.
  • 결과적으로 이러한 단점 때문에 멀티 스레드 프로그래밍은 상당히 난해하며 스레드에 대해서 정확히 알고 있어야 한다.

 

 

'OS' 카테고리의 다른 글

synchronous, asynchronous, blocking, non-blocking  (0) 2023.01.20
Race Condition  (0) 2022.11.21