[F-Lab 모각코 챌린지 20일차]

부추·2023년 6월 21일
0

F-Lab 모각코 챌린지

목록 보기
20/66

TIL

  1. 프로세스와 스레드
  2. race condition 코드 작성해보기
  3. deadlock 코드 작성해보기




1. 프로세스와 스레드

프로세스와 스레드는 모두 CPU의 실행 단위이다.

프로그램은 한마디로 컴퓨터가 실행할 수 있는 명령어가 모인 파일이다. 프로세스는 실행중인 프로그램의 인스턴스이다. 한마디로, 프로그램이 실행되면 프로세스인 것이다. 프로그램의 내용이 메인 메모리에 올라오고, 실행에 필요한 일련의 자원이 갖춰지면 프로그램은 비로소 프로세스가 된다.

프로세스는 하나의 독립된 실행 단위로서, 고유한 pid, 메모리 영역(여기서 말하는 메모리란 코드, 데이터, 힙과 스택 영역을 모두 아우른다), 프로세스에서 관리하는 파일과 레지스터 값들을 가지고 이들은 PCB(Process Control Block)에 저장되고 관리된다. PCB에는 이 외에도 CPU와 OS가 프로세스를 관리하는데 필요한 여러가지 정보가 존재한다.

CPU는 주어진 명령어를 실행하는 객체이다. 프로세스 메모리의 코드 영역에 있는 코드를 하나씩 가져와(fetch) 읽고 해석한 뒤(decode) 실행한다(execution). 실행 결과를 메모리에 작성하기도 하고 (write back)

스레드란 프로세스 내의 작은 실행 단위를 말한다. PCB에 저장된 정보 전체를 가지고 있는 프로세스와는 달리, 스레드는 자신의 스택 영역과 sp 및 pc만을 가진다. 스택 영역엔 함수의 인자, 지역 변수 같이 하나의 함수를 실행하는데 필요한 지역 자원들이 내포되어 있다. sp는 이런 스택 영역을 가리키는 레지스터 포인터, pc는 실행할 명령어 주소가 담긴 레지스터 포인터인 것을 생각해 봤을 때, 스레드가 하나의 실행단위가 될 수 있다는 사실은 자명해 보인다.

프로세스간 실행 전환이 일어날 경우, 메모리와 레지스터, 파일 정보를 포함한 프로세스의 모든 자원들이 갈아끼워져야하는 부담이 있다. 이같은 과정을 "Context Switching"이라고 한다. 같은 프로세스의 스레드는 프로세스의 대부분을 공유하기 때문에 컨텍스트 스위칭이 일어났을 때 그 비용이 프로세스간 컨텍스트 스위칭보다 훨씬 덜하다는 장점이 있다. 그러나 멀티 프로세스보다 덜하다 뿐이지 멀티스레드도 남용하면 문맥 전환의 비용이 꽤 세다. 스레드 생성 자체가 비용이 생기는 작업이기도 하고..


멀티 스레드와 멀티 프로세스라는 단어가 나왔다. 일반적으로 컴퓨터는 여러 개의 프로세스/스레드가 동시에 진행되고 있다. 그렇다고 진짜 CPU에서 동시에 2개 이상의 명령어를 수행하는 것은 아니다. 여러 개의 프로세스를 시분할로 번갈아가면서 수행하고 있을 뿐이다. 타자를 치는 것, 타자를 친 결과가 벨로그 에디터에 전달되는 것, 에디터에 전달된 내용이 서버에 올라가는 것, 그 내용이 다시 노트북의 화면에 나타나는 것은 전부 다른 프로세스가 담당한다. 정확히 따지자면 물론 다르겠지만 일련의 과저 키보드IO, 크롬, 커널의 네트워크 관련 프로세스, 화면IO 프로세스 등이 각 과정에 참여할 것이다. 각 프로세스의 명령어들은 CPU가 아주 짧은 시간동안 실행할 수 있는 작은 단위로 나눠져 번갈아가며 실행되고, 이 과정이 매우 빠르게 동작하면 동시에 여러 프로세스가 동작하는 것처럼 보인다. 이것이 멀티 프로세스/스레드의 진실이고 이러한 특성을 동시성이라고 부른다.




2. race condition

멀티스레딩을 활용하면 프로세스의 여러 부분을 동시에 실행할 수 있기 때문에 빠른 동작이 가능하지만, 여기엔 매우 커다란 문제점이 있다. 여러 스레드가 공유하는 프로세스 자원에 대한 race condition, 즉 동시성 이슈가 발생할 수 있다는 점이다. race condition이란 경주마들 중 누가 1등을 할지 모르는 상황처럼, 여러 스레드가 하나의 공유 자원에 접근하여 연산을 했을 때 그 결과를 예측할 수 없는 상황을 의미한다.

마을의 발전을 위해 여러 시민들이 기부금을 내는 상황이다. 마을인 Village 객체엔 마을에 모인 기부금인 money 필드가 있다. 그리고 기부금을 늘릴 수 있는 addMoney() 메소드를 추가했다. 마을에 기부를 하는 사람인 Contributor 객체는 멀티스레드로 구현하기 위해 Thread 클래스를 상속받았고, run() 메소드는 Village 객체의 addMoney()를 1000번 호출하는 것으로 구현했다.

public class Village {
    int money;
    
    public void addMoney() {
        money++;
    }
}

public class Contributor extends Thread {
    private final String name;
    private final Village village;

    public Contributor(String name, Village village) {
        this.name = name;
        this.village = village;
    }

    @Override
    public void run() {
        for (int i=0; i<1000; i++) {
            village.addMoney();
        }
        System.out.println(name + "님이 1000번 기부를 한 후 마을에 " + village.money+" 원이 모였습니다.");
    }
}

같은 마을에 사는 10명의 Contributor 객체를 만들고, 각각 동작하도록 코드를 구성했다.

public class Contribute {
    public static void main(String[] args) {
        Village village = new Village();

        Contributor [] contributor = new Contributor[10];
        for (int i = 0; i<10; i++) {
            contributor[i] = new Contributor("기부자"+i,village);
        }

        for (int i=0;i<10;i++) {
            contributor[i].start();
        }
    }
}

10명의 기부자가 각각 1000번 기부를 했으므로 addMoney 결과는 10000원이 되어야 할 것이다. 해당 main 메소드를 실행시켜보자.

결과는?

기부자4님이 1000번 기부를 한 후 마을에 4960 원이 모였습니다.
기부자5님이 1000번 기부를 한 후 마을에 5960 원이 모였습니다.
기부자9님이 1000번 기부를 한 후 마을에 9960 원이 모였습니다.
기부자7님이 1000번 기부를 한 후 마을에 7960 원이 모였습니다.
기부자6님이 1000번 기부를 한 후 마을에 6960 원이 모였습니다.
기부자3님이 1000번 기부를 한 후 마을에 3960 원이 모였습니다.
기부자0님이 1000번 기부를 한 후 마을에 1127 원이 모였습니다.
기부자8님이 1000번 기부를 한 후 마을에 8960 원이 모였습니다.
기부자1님이 1000번 기부를 한 후 마을에 2000 원이 모였습니다.
기부자2님이 1000번 기부를 한 후 마을에 3020 원이 모였습니다.

음..... 10000이라는 숫자는 눈을 씻고 찾아봐도 보이질 않는다. 혹시 모르니 한 번 더 실행시켜보자.

기부자3님이 1000번 기부를 한 후 마을에 3855 원이 모였습니다.
기부자6님이 1000번 기부를 한 후 마을에 6677 원이 모였습니다.
기부자4님이 1000번 기부를 한 후 마을에 4855 원이 모였습니다.
기부자9님이 1000번 기부를 한 후 마을에 9677 원이 모였습니다.
기부자2님이 1000번 기부를 한 후 마을에 2855 원이 모였습니다.
기부자8님이 1000번 기부를 한 후 마을에 8677 원이 모였습니다.
기부자5님이 1000번 기부를 한 후 마을에 5792 원이 모였습니다.
기부자7님이 1000번 기부를 한 후 마을에 7677 원이 모였습니다.
기부자1님이 1000번 기부를 한 후 마을에 1855 원이 모였습니다.
기부자0님이 1000번 기부를 한 후 마을에 1855 원이 모였습니다.

10000이 보이기는커녕, 앞선 실행과도 다른 출력문이다. 10명이 1000번이나 기부를 했는데 그 돈이 제대로 모이지 않았다니..

이것이 Race Condition이다. 프로그램의 실행 결과를 알 수 없는 것. 정해진 역할을 수행하고 정해진 결과를 내놓아야 하는 컴퓨터 프로그램의 결과가 알 수 없게 된다는 것은 좋지 않다.




3. deadlock 실습

다섯 명의 철학자가 하나의 원탁에 앉아 식사를 한다. 각각의 철학자들 사이에는 포크가 하나씩 있고, 앞에는 접시가 있다. 접시 안에 든 요리는 스파게티 같은 면 요리이기 때문에 식사를 하기 위해서는 동시에 두 개의 포크가 필요하다. 그리고 각각의 철학자는 다른 철학자에게 말을 할 수 없으며, 번갈아가며 각자 식사하거나 생각하는 것만 가능하다. 따라서 식사를 하기 위해서는 왼쪽과 오른쪽의 인접한 철학자가 모두 식사를 하지 않고 생각하고 있어야만 한다. 또한 식사를 마치고 나면, 왼손과 오른손에 든 포크를 다른 철학자가 쓸 수 있도록 내려놓아야 한다. 이 때, 어떤 철학자도 굶지 않고 식사할 수 있도록 하는 방법은 무엇인가?

  1. 5명의 철학자는 각각 왼쪽의 포크를 집는다.
  2. 5명의 철학자는 각각 오른쪽의 포크를 집는다.
  3. 만약 집을 포크가 없을 경우, 집을 포크가 생길 때까지 대기한다.
  4. 양 손에 포크가 모두 있어야 식사를 시작한다.
  5. 식사가 끝나고, 왼 손에 들린 포크를 내려놓는다.
  6. 오른손에 들린 포크를 내려놓는다.

..를 코드로 표현하면 다음과 같다. 각 철학자는 스레드로 구현했다. 하나의 포크는 한 명의 철학자에만 점유될 수 있으므로 synchronized 키워드와 wait(), notify()를 통해 이를 구현했다.

public class Fork {
    synchronized void picked() throws InterruptedException {
        wait();
    }

    synchronized void putDowned() {
        notify();
    }
}

public class Spagetti {
    String name;
    Spagetti(String name) {
        this.name = name;
    }
    void eaten() {
        System.out.println(name + " 먹힘");
    }
}

public class Philosopher extends Thread {
    String name;
    Fork left, right;
    Spagetti spagetti;

    Philosopher(String name, Fork left, Fork right, Spagetti spagetti) {
        this.name = name;
        this.left = left;
        this.right = right;
        this.spagetti = spagetti;
    }

    @Override
    public void run() {
        try {
            System.out.println(name + "가 왼쪽 포크를 집어든다.");
            left.picked();
            System.out.println(name + "가 오른쪽 포크를 집어든다.");
            right.picked();

            System.out.println(name + "가 스파게티를 먹는다.");
            spagetti.eaten();

            System.out.println(name + "가 왼쪽 포크를 내려놓는다.");
            left.putDowned();
            System.out.println(name + "가 오른쪽 포크를 내려놓는다.");
            right.putDowned();

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

그리고 이를 실행할 main() 메소드는 아래와 같다.

public class Table {
    public static void main(String[] args) {
        Fork [] forks = new Fork[6];
        Spagetti [] spagettis = new Spagetti[5];
        for (int i=0; i<5; i++) {
            forks[i] = new Fork();
            spagettis[i] = new Spagetti(i+"번 스파게티");
        }
        forks[5] = forks[0];

        Philosopher [] philosophers = new Philosopher[5];
        for (int i=0; i<5; i++) {
            philosophers[i] = new Philosopher(i+"번 철학자",forks[i],forks[i+1],spagettis[i]);
        }

        for (Philosopher philosopher : philosophers) {
            philosopher.start();
        }
    }
}

원형 식탁을 구현하기 위해, forks 배열의 마지막 원소와 첫번째 원소가 같은 객체를 가리킬 수 있도록 했다.

위 메소드를 실행시키면?!
시작한지 30초가 지났는데도 끝날 생각을 안한다. 이 프로그램은 중간에 interrupt가 들어오지 않는 이상 영원히 끝나지 않을 것이다. 철학자들은 굶어죽고 말겠지ㅜㅜ

식사하는 철학자 문제는,

  1. 상호 배재(Mutual Exclusion) : 한 포크는 한 사람에게만 할당 가능
  2. 점유 및 대기(Hold & Wait) : 오른쪽 포크가 available 할 때까지 왼쪽 포크를 내려놓지 않음
  3. 비선점(Non preemtive) : 옆 철학자 멱살잡고 포크 내놓으라고 하지 않음
  4. 환형 대기(Circular Wait) : 포크-철학자간 할당 그래프를 그려보면 원 모양임

위 4가지 조건을 만족하는 DEADLOCK 상황임을 알 수 있다.


데드락을 피하기 위해 상기한 4가지 조건중 적어도 하나를 만족하지 않도록 사전에 예방하는 방법을 사용할 수 있다. 여러 사람이 동시에 쓸 수 있는 슈퍼-포크를 개발한다거나, 일정 시간이 지나도 자원을 얻을 수 없는 경우 내가 가진 포크를 내려놓는다거나, 다른 철학자의 포크를 강제로 빼앗을 수 있도록 한다거나, 애초에 원탁이 아닌 일렬형 테이블에서 식사하도록 한다거나..

현대 OS에서 프로세스에게 컴퓨터 자원을 할당했을 때 역시 데드락이 발생하는데, 보통은 detect & recovery 작전을 쓴다. 데드락이 발생하는 부분이 없는지 주기적으로 체크하고 발생했을 경우 자원을 점유하는 프로세스들을 조금씩 죽여나간다.



REFERENCE

https://m.blog.naver.com/qriositylog/221788147057
https://buchu-doodle.tistory.com/186

profile
부추튀김인지 부추전일지 모를 정도로 빠싹한 부추전을 먹을래

0개의 댓글