[ TIL ] 쓰레드 동기화와 스케줄링

charco·2021년 10월 22일
0

나도TIL

목록 보기
54/55

프로세스와 쓰레드

프로세스

프로세스란 간단히 말해서 실행 중인 프로그램이다.
프로그램을 실행하면 OS로부터 필요한 자원을 할당받아 프로세스가 된다.

쓰레드

모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재한다.
둘 이상의 쓰레드를 가진 프로세스를 멀티쓰레드 프로세스라고 한다.

쓰레드를 프로세스라는 공장에서 작업을 처리하는 일꾼으로 생각하면 이해하기 쉬울 것이다.

멀티쓰레딩의 장단점

장점

  • CPU의 사용률을 향상시킨다.
  • 자원을 보다 효율적으로 사용할 수 있다.
  • 사용자에 대한 응답성이 향상된다.
  • 작업이 분리되어 코드가 간결해진다.

단점

  • 여러 쓰레드가 하나의 자원을 공유하는 경우도 있기 때문에 동기화, 교착상태와 같은 문제들을 고려해서 신중히 프로그래밍해야 한다.

스케줄링

먼저 쓰레드의 LifeCycle을 알아보자.

총 다섯가지 상태가 있다.
New 는 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태다.
Terminated 는 쓰레드의 작업이 종료된 상태다.
중요한 것은 Runnable, Running, Blocked 이다.

Runnable은 실행 대기상태이다. 실행되고 있진 않다.
Running 은 말 그대로 실행중인 상태다.
Blocked 는 I/O 연산, 동기화블럭 등에 의해 블록킹당한 상태다.
Timed_Waiting은 일시정지 상태이다.

그림에서와 같이 새로 생성된 쓰레드는 바로 실행되는 것이 아니고
먼저 실행 대기 상태로 들어가게 된다.

쓰레드의 실행을 제어할 수 있는 메서드

  • static void sleep(long mills)
    - 지정된 시간동안 쓰레드를 일시정지시킨다. 지정한 시간이 지나고 나면, 자동적으로 실행 대기상태가 된다.

  • void join(), join(long mills)
    - 지정된 시간동안 특정한 쓰레드가 실행되도록 한다. 지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.

  • void interrupt()
    - sleep()이나 join()에 의해 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만든다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지 상태를 벗어나게된다.

  • void stop()
    - 쓰레드를 즉시 중지시킨다.

  • suspend()
    - (Deprecated) 쓰레드를 일시정지 시킨다. resum()을 호출하면 다시 실행 대기상태가 된다.

  • void resume()
    - (Deprecated) suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기상태로 만든다.

  • static void yield()
    - 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보한다.


쓰레드의 동기화

멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다.

예를 들어 쓰레드 A 와 쓰레드 B 가 동시에 실행되고 있다고 해보자.
이 둘은 모두 x 라는 값이 10인 int형 변수에 사칙연산을 하려고 한다.
A는 x에 3을 더하고 B는 x에서 3을 뺄 것이다.
우리가 원하는 결과는 10이다.

  1. A 가 x를 읽어온다.
  2. A 가 x에 3을 더한다. x 에 더한 값을 저장하려고 한다.
  3. 그 사이에 B가 x를 읽는다.
  4. B 가 x에서 3을 뺀다.
  5. A 가 B가 연산을 하고 있는 사이에 13을 x에 저장한다.
  6. 마지막으로 B 가 x에 3을 뺀 값을 저장한다.

x의 값을 뭘까?
...
...

7이다.
왜냐하면 A가 작업을 하는 도중에 B가 끼어들어 작업을 해버렸기 때문이다.
A가 작업하는 도중에 다른 쓰레드들이 작업을 방해하지 못하게 하는 것이 동기화이다.

그러면 동기화는 코드로 어떻게 구현할까?
synchronized 키워드를 사용하면 된다.

synchronized

위에서 A 가 x에 대해 3을 더하고 다시 x에 넣을때,
다른 쓰레드가 중간에 끼어들지 않게 해야 한다.
아래의 코드를 보자

// 이 메서드는 이제 임계 영역이다.
public synchronized void sum(int y){
	x += y;
}

이렇게 메서드를 선언하면 메서드 전체가 임계영역이 된다.
임계 영역은 다른 쓰레드가 끼어들지 못하도록 지정한 영역이다.
아래와 같이 메서드의 일부분만 임계영역으로 지정할 수 도 있다.

public void sum(int y){
	// 쓰레드가 this의 락을 얻게 한다.
	synchronized(this){
    		x += y;
        }
}

() 안의 참조변수는 lock을 걸고자 하는 객체를 참조하는 것이어야 한다.
이 블럭을 synchronized 블럭이라고 부른다.
이 블럭의 영역 안으로 들어가면서부터 쓰레드는 () 안에 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock 을 반납한다.

모든 객체는 lock을 하나씩 갖고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다.
다른 쓰레드들은 lock을 얻을때까지 기다리게 된다.

wait() 과 notify()

만약 어떤 쓰레드가 락을 보유한채로 계속 공회전한다면
다른 쓰레드들은 모두 해당 객체의 락을 기다리느라 다른 작업들도 원활히 진행되지 않을것이다.
wait()는 이런 상황에서 쓰레드가 일단 lock을 반납하고 기다리게 한다.
notify()는 기다리는 쓰레드가 다시 락을 얻어 작업할 수 있게 한다.

notify()가 호출되면, waiting pool 에서 기다리고 있던 쓰레드들 중 임의의 쓰레드에게 통지한다.
결국, 정말 운이 나쁘면 단 한번도 통지받지 못해 영원히 기다리게 되는 쓰레드가 있을 수 있다는 뜻이다.

기아 현상과 경쟁 상태

기아 현상은 위에서 설명한 것과 같은 현상이다.
한 객체의 waiting pool 에서 기다리는 쓰레드 중 하나는 오랜시간동안 lock을 획득하지 못할 가능성이 있다.
이 현상을 막으려면 notifyAll()을 사용해야 한다.

notifyAll()로 여러 쓰레드에게 통지를 하면 그 쓰레드들이 하나의 lock을 얻기 위해 경쟁하게 된다. 이것을 경쟁상태라고 한다.

이러한 현상을 해결하려면 java.util.concurrent.locks 패키지가 제공하는 lock 클래스들을 이용하면 된다.


volatile 키워드

코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다.
다시 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을때만 메모리에서 읽어온다.
그러다 보니 메모리에 저장된 값과 캐시에 저장된 값이 다른 경우가 발생할 수 있다.

volatile 키워드를 변수에 붙히며느 코어가 변수의 값을 읽어올때 캐시가 아닌 메모리에서 읽어온다.
그래서 캐시와 메모리간의 값의 불일치가 해결된다.

참고로 쓰레드가 synchronized 블럭으로 들어갈 때와 나올 때도 캐시와 메모리간의 동기화가 이루어진다.

profile
아직 배우는 중입니다

0개의 댓글