공부방/JAVA

멀티스레드와 동기화 문제 - 백기선 자바라이브스터디

EVO. 2023. 9. 20. 01:44

분명 프로젝트를 진행하면 싱글 메인스레드만을 돌리는 프로젝트를 보기가 오히려 어려울 것이다. 두 개 이상의 스레드를 돌리면서 각종 동시성 문제와 데드락을 해결하는 과정을 거치는 경험을 갖게될텐데 여기서 생기는 문제를 쉽게 해결하기 위해 이번 기회에 한번 쭉 정리하겠다. 

Thread 클래스와 Runnable 인터페이스

프로세스와 스레드의 차이 부터 알고 있어야 스레드를 이해할 수 있다고 생각한다. 운영체제에서 자세히 살펴볼 수 있으니 간단하게 언급만 하고 넘어가겠다 

  • process : 프로그램이 메모리에 적재되고 CPU 자원을 할당받아 프로그램이 실행되고 있는 상태 
  • thread : 할당받은 자원을 이용하는 실행의 단위이고 프로세스 내에서 여러 개 생길 수 있다 

출처: https://www.javatpoint.com/process-vs-thread

하나의 프로세스는 코드와 데이터 영역은 여러 스레드가 공유를 하지만 각 스레드는 스택의 영역은 공유하지 않는다. 이는 스레드만의 공유 영역이다. 그리고 프로세스 하나에는 무조건 메인스레드 하나가 생긴다. 

 

thread를 생성하는 방법에는 두가지 방법이 있다 

 

1. Runnable 인터페이스 사용

2. Thread 클래스를 사용 

Runnable

Runnable 구현만으로는 스레드를 생성할 수 없다. 해당 Runnable구현체를 Thread인자에 넣어주어야 한다. 

Thread

Thread 클래스를 상속받고 run메소드 부분을 오버라이딩 하여 클래스를 만들고 사용하면 된다. 

둘을 보면 굳이 Runnable을 상속받아서 그 Runnable구현체를 Thread클래스에 집어넣는게 오히려 번거로워 보이지만 이렇게 하는 이유는 확장을 위해 만드는 것이다. 만약 Thread클래스를 상속받으면 더이상 필요한 클래스를 상속받을 수 없기 때문이다.

굳이 확장할 필요가 없으면 그냥 Thread클래스를 상속받아서 사용하면 된다.

확장에 대한 예시는
https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx18.java

 

run() vs start()

위의 코드를 보면 새로운 스레드를 실행시키기 위해 run()메소드를 호출하지 않고 start()메소드를 실행하고 있는 것을 볼 수가 있다.

만약 thread의 run을 실행시키면 다음 그림과 같이 실행된다.

main 스레드의 스택 영역

메인스레드의 스택영역에 run 메소드가 호출되고 메인스레드에서 run()을 구동 시킨다. 이는 새로운 스레드를 생성하는 게 아닌 싱글 스레드로 동작한다는 것이다. 

이렇게 구현하고 실행하면 

main스레드만 찍히는 것을 확인 할 수가 있다. start()로 실행하면 어떨까 start()의 코드를 보면 다음과 같이 구성되어 있다. 

threadStatus = 0 은 스레드가 'NEW'에 해당하며 

먼저 스레드의 상태를 체크해서 0이 아니면 IllegalThreadStateException의 예외를 발생시킨다. 

 

만약 스레드가 NEW의 상태인 경우 스레드 그룹에 이 객체를 추가하고 start0()메소드를 실행시킨다. 

start0의 메소드는 네이티브 코드라 해당 구현체는 여기서 확인할 수 있다

https://github.com/openjdk/jdk/blob/master/src/hotspot/share/prims/jvm.cpp#L2946

내가 보기엔 아직 어렵고 다른 블로그 글의 말을 첨언하면 해당 코드는 call stack 사이즈를 계산하여 Thread를 생성하고 start한다. start하게 되면 run()메서드가 호출된다. 

 

 

쓰레드의 상태

아까 위에서 스레드가 NEW인 경우가 스레드의 여러 상태 중 하나이다. 

  • NEW : 쓰레드 객체는 생성되었지만, 아직 시작되지 않은 상태
  • RUNNABLE : 쓰레드가 실행중인 상태
  • BLOCKED : 쓰레드가 실행 중지 상태이며 , 락이 풀리기를 기다리는 상태
  • WAITING : 쓰레드가 대기중인 상태
  • TIMED_WAITING : 특정 시간만큼 쓰레드가 대기중인 상태
  • TERMINATED : 쓰레드가 종료된 상태 

 

stop(),suspend(),resume()는 쓰레드를 즉시 Blocked상태로 만들고 깨우는 방식인데 교착상태(deadlock)을 일으키기 쉽게 작성되어 있으므로 deprecated 되었다.

 

Thread sleep(long millis) 메소드

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx7.java                   

sleep메소드는 InterruptedException 예외를 던질 수 있기 때문에 해당 예외를 처리해야한다. 이 예외는 interrupt()메소드를 호출하면 일시정지상태인 쓰레드를 깨워서 실행대기 상태로 만든다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지상태를 벗어나게 된다. 

 

interrupt()와 interrupted()

interrupt()는 InterruptedException 예외를 발생시키면서 WAITING상태에 있던 스레드를 깨워 실행대기(RUNNABLE) 상태로 만든다. 

interrupted상태에서 약간 헷갈리는 점이 있다. 이부분은 한번 보고 넘어가면 좋을 듯 싶다.

 

1. interrupt()는 interrupted상태를 false에서 true로 변경. 

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx13.java

사용자의 입력을 끝내면 intrrupted는 true로 바뀌고 카운트다운이 중간에 멈춘다.

 

2. interrupt()를 호출하면 InterruptedException 발생 

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx14.java

위 코드를 실행해보면 알겠지만 사용자의 입력을 끝내도 카운트 다운이 멈추지 않는다. 그 이유는 예외가 터지고 interrupted상태는 false로 자동 초기화되기 때문이다. 

 

join()

인자로 시간을 넘기지 않으면, 해당 쓰레드가 작업을 모두 마칠 때까지 기다리게 된다. 작업 중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을 때 사용한다.

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx19.java

위 코드를 보면 join()을 사용하지 않았으면 main쓰레드는 바로 종료되었겠지만 join()으로 쓰레드 th1,th2작업이 마칠 때까지 main쓰레드가 기다리도록 했다. 

 

yield()

멀티쓰레드 환경에서 OS의 스케줄러는 한 쓰레드에게만 종료될때까지 무한정 시간을 주지 않는다. 보통은 공평하게 시간을 배분하는 쪽으로 선택하는데 라운드로빈이라고 한다. 예를들어 스케줄러에 의해 1초의 실행시간을 할당받은 스레드가 0.5초 작업한 상태에서 yield()가 호출되면, 나머지 0.5초는 포기하고 실행대기상태(RUNNABLE)로 바뀐다. 

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx18.java

스레드가 suspend 메소드를 호출하면(deprecated된 suspend가 아니라 직접만든 메소드) else문을 실행하면서 해당 스레드는 바로 다른 스레드로 양보를 하는 것을 실행결과에서 볼 수가 있다. 

 

쓰레드의 우선순위

void setPriority(int newPriority) // 쓰레드의 우선순위를 지정한 값으로 변경한다.
int getPriority() // 쓰레드의 우선순위를 반환한다.
  • 쓰레드는 우선순위라는 속성을 가지고 있고 해당 값이 클 수록 더 많은 작업시간을 갖게 할 수 있다.
  • 쓰레드가 가질 수 있는 우선순위의 범위는 1~10
  • 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로 부터 상속받는다.
  • main쓰레드는 우선순위가 5이고 main메서드내에서 생성하는 쓰레드의 우선순위는 디폴트로 5가 된다. 
  • 하지만 멀티코어에서는 우선순위를 다르게 줘도 누가 먼저 빨리 실행되기를 기대할 수 없다. 

대충 느낌만 파악하자 (정확한 그림은 아니다)

Main 쓰레드

우리가 생성하는 모든 쓰레드는 main메소드내에서 생성할 수 밖에 없기 때문에 main쓰레드 하위 쓰레드라고 부른다.

쓰레드 그룹

  • 쓰레드 그룹은 서로관련된 쓰레드를 그룹으로 다루기 위함으로 폴더 관리 느낌으로 보면 되겠다.
  • 모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 하며 쓰레드그룹을 지정하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 그룹에 속하게 된다.
  • 쓰레드를 쓰레드 그룹에 포함시키려면 Thread 생성자를 이용한다. 

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx9.java

위의 코드를 그림으로 나타내면 다음과 같다. 

실행결과를 보면 thread 그룹 간을 계층별로 나타내어 출력한 것을 볼 수 있다. 

 

Deamon Thread

  • Main 쓰레드(주 스레드)의 작업을 돕는 보조적인 역할을 하는 쓰레드 
  • Main 쓰레드(주 스레드)가 종료되면 데몬 쓰레드는 강제적으로 종료된다
  • 보조역할을 하는 쓰레드 (종속되어 있는 스레드)

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx10.java

해당 클래스를 setDeamon(true)로 하여 데몬쓰레드로 설정하였다. 이렇게 하면 run()코드가 무한반복이었지만 main스레드가 종료되면 데몬스레드로 설정한 t 역시 같이 종료된다. 정 믿지 못하겠으면 setDeamon을 지워보자. 종료되지 않은 코드를 볼 수 있을 것이다. 

 

동기화(Synchronize)

  • 여러 개의 쓰레드가 어느 자원에 동시 접근 하는 것을 막는 것이다.
  • 이 동기화를 수행하는 방법은 총 3가지이다 
    • Synchronized 키워드 (블로킹 방식)
    • Atomic 클래스 (논블로킹)
    • Volatile 키워드 

Synchronized 키워드

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadEx21.java

위의 코드에서 withdraw 함수를 보자 멀티스레드 환경에서 이를 실행시켜보면 말도 안되는 일이 생긴다.

분명 if문을 두어서 잔금보다 출금하려는 돈이 많으면 들어가지 못하도록 로직을 만들었지만 멀티스레드환경에서는 이렇게 로직을 짜면 문제가 생긴다. 

  • 스레드1 스레드2가 있다면 먼저 스레드1이 if문을 보고 "출금을 할 수 있겠다" 싶어 if문 안으로 들어온다.
  • 그리고 if문 안의 로직을 실행하기 이전에 스케줄러에 의해 컨텍스트 스위칭이 일어나 스레드2로 바뀐다.
  • 스레드2도 역시 if문을 보고 아직 스레드1이 출금하기 직전이기 때문에 스레드2 입장에서는 가능하다 생각하고 if문 안으로 들어온다
  • 스레드2는 if문의 모든 로직을 수행하여 잔금이 0이 되었다
  • 스레드1로 컨택스트스위칭이 일어나고 또 출금을 하면서 -200으로 된다.

이러한 문제는 원자성 때문에 생기는 문제이다.

 

원자성

원자성이란, 공유 자원에 대한 작업의 단위가 더이상 쪼갤 수 없는 하나의 연산인 것처럼 동작하는 것이다. CPU는 하나의 명령단위는 컨택스트스위칭이 일어나기전에 무조건 실행한다.

 

예를들어 x++; 은 x = x+1; 로 풀어낼 수 있는데 이 코드는 한줄로 적혀있어 한번에 실행할것 같지만 원자성을 고려한 CPU에게는 이는 두개의 명령어로 쪼개진다. 

실제로 바이트 코드에도 ICONST 와 ISTORE은 따로 명령어가 존재하는 것을 확인할 수 있으며 쓰레드는 CONST만 수행하고 컨택스트스위칭이 일어나 다른 스레드가 먼저 STORE까지 수행할 수 있다. 

 

int x = 3; 에서 x++; 을 수행하면 th1이 x가 3이구나 라는 데이터를 레지스터에 갖고 온상태에서 th2로 교체되고 th2도 cpu캐시에서 x가 3으로 확인하고 3을 가져오고 4로 만들고 저장시켰다. 그다음 다시 컨텍스트스위칭이 일어나 th1이 3을 1을 더해 4로 만들고 캐시에 4로 저장한다면 5가 저장해야되지만 4로 저장되는 불상사가 생기는 것을 볼 수가 있다.

 

먼저 원자성을 해결할 수 있는 synchronized부터 설명해본다. 

메소드 전체에 락을 거는 방법

synchronized키워드만 붙이면 정상적으로 락을 획득하고 반납하는 것을 확인할 수 있다.

 

두번째로 각각의 인스턴스를 만들고 메서드를 실행시켜봤다

결과를 보면 락이 제대로 안걸리는 것을 알 수가 있다. 서로 다른 객체로 접근하는 것이니 이 부분에 대해서 조심하자 

static synchronized method

static이 포함된 synchronized method방식은 인스턴스가 아닌 클래스 단위로 lock이 발생한다. 아까 실패한 예에서 static 키워드를 집어넣으면 정상적으로 실행된다. 

 

synchronized block

인스턴스의 block 단위로 lock을 건다. 

  • block으로 지정해주는데 좀 더 좁은 범위로 지정할 수 있다.
  • 이때 this는 위의 RunnableEx21 객체를 의미하고 있고 block이 메소드 전체에 적용되고 있기 때문에 method단위로 lock을 거는 것과 같다 
  • synchronized block도 method와 동일하게 인스턴스에 대해서 적용된다. 따라서 lock은 각각의 인스턴스 별로 관리된다. 

 

block단위로 지정할때 this가 아닌 다른 객체로 집어넣는 방법

  • synchronize 블록은 항상 특정 객체 참조와 함께 사용된다. 이 객체참조는 "모니터" 또는 "락" 역할을 한다. 
  • 어떤 객체 참조를 synchronized 블록에 넣느냐는 어떤 데이터 혹은 작업을 동기화하고 싶은 지에 따라 결정된다.
  • 공유데이터 자체나 그와 직접적으로 연관된 객체가 일반적으로 가장 좋은 선택이다.
synchronized(RunnableEx21.class) {
    // critical section: only one thread can execute this at a time
}

 

 

static메소드 안에 동기화 블록

 

wait()와 notify()

synchronized로 동기화 해서 공유 데이터를 보호하는데 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 해야한다. 

https://github.com/minsang-alt/java/blob/main/src/main/java/com/example/javaspring/thread/ThreadWaitEx1.java

void wait(); // notify()나 notifyAll()이 호출될때까지 대기상태
void notify();
void notifyAll();

//매개변수에 지정된 시간동안 기다린다
void wait(long timeout);
void wait(long timeout, int nanos);
  • Object클래스에 정의되어 있다
  • 동기화 블록 내에서만 사용할 수 있다
  • wait()는 키를 가진 객체가 키를 반납하고 해당 동기화블록을 가진 객체의 waiting pool에서 대기한다 
  • notifyAll()와 notify()이 호출된 객체의 waiting pool에 대기중인 쓰레드를 깨우는 것이다
  • 만약 notify()로 대기중인 스레드를 깨우면 어떤 스레드를 깨울지 임의로 선택한다.
  • notifyAll()은 대기중인 모든 스레드중 먼저 키를 낚아채는 스레드가 동기화블럭을 수행할 수 있다 

synchronized의 문제점

앞에서 설명한 synchronized는 블로킹 방식으로 멀티 스레드 환경에서 공유 자원을 동기화하는 키워드이다. 하지만 이 방식은 성능이슈가 있다. 특정 스레드가 해당 블럭 전체에 lock을 걸면, 해당 블럭에 접근할려는 스레드들은 그동안 blocking 되어 아무작업을 못한 채 시간을 낭비한다. 또한 blocking 상태의 스레드를 실행대기 상태로 바꾸는데에도 시스템의 자원이 필요하다. 결국 성능 저하로 이어진다. 

 

Atomic 

  • atomic 변수는 원자성을 보장하며 논블로킹 방식이다.
  • atomic의 핵심 동작 원리는 CAS(Compare And Swap) 알고리즘이다.

CAS 알고리즘 이란

  • 인자로 기존 값과 변경할 값을 전달한다.
  • 기존 값이 현재 메모리가 가지고 있는 값과 같다면 변경할 값을 반영하며 true를 반환한다.
  • 반대로 기존 값이 현재 메모리가 가지고 있는 값과 다르다면 값을 반영하지 않고 false 반환한다.

앞에서 x++ 예시를 생각하면 CAS알고리즘이 많은 장점을 가질 것이라고 예측 할 수 있다.

 

이제 false를 반환하는 경우에는 무한 루프를 구성하여 변경된 값(다른 스레드에 의해 변경된 메모리 값)을 읽고 같은 시도를 반복하거나, 다른 더 중요한 작업이 있으면 다른 작업을 해도 된다. 게다가 스레드의 상태를 변경하지 않아도 돼서 성능면에서도 우수하다. 

 

위의 코드는 AtomicInteger 클래스로 동기화문제를 해결하였다. 

atomicInteger.get() = 200000
count = 155442

 

incrementAndGet()메소드를 좀 더 살펴보면 다음과 같이 구현되어 있다.

이런식으로 적혀있고 getAndAddInt로 들어가면 CAS알고리즘의 로직을 구현하고 있다. 

weakCompareAndSetInt()메소드에 메모리에 저장된 값과 현재값과 비교하여 동일하면 메모리에 변경한 값을 저장하고 true을 반환하여 while문을 빠져나간다. 

 

그리고 value를 보면 volatile 키워드를 붙이면서 가시성은 해결했지만 원자성문제는 해결 못하지 않을까 의구심이 있었지만 

public final void set(int newValue) {
        value = newValue;
 }

get과 set 메소드를 보면 원자성 문제가 생기지 않는 단순 대입과 추출 로직으로 만들어졌다.

 

가시성과 volatile을 미리 언급했는데 가시성이 뭔지 알아보자

 

가시성

Thread1에서 stopped변수를 true로 바꾸면 코어1의 캐시에서 true로 변경하고 메인메모리에 true로 반영된다. 하지만 메모리와 CPU사이에 반영되는데 꽤 오랜시간이 걸려 core2의 캐시메모리에서는 stopped가 변경사항이 있는 지 전혀 알지 못한다. 그래서 그냥 stopped변수가 있으니 바로 false로 가져와서 수행한다. 이런 캐시와 메인메모리 불일치 과정을 고려해야 할 필요가 있다

 

volatile

이 volatile키워드는 가시성 문제를 해결하는 방법으로 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간의 불일치가 해결된다.

 

volatile boolean stopped = false;

 

 

 

출처

https://steady-coding.tistory.com/568

 

[Java] atomic과 CAS 알고리즘

java-study에서 스터디를 진행하고 있습니다. synchronized의 문제점 synchronized는 blocking을사용하여 멀티 스레드 환경에서 공유 객체를 동기화하는 키워드이다. 그러나 blocking에는 여러 가지 단점이 존

steady-coding.tistory.com

자바의 정석 

 

https://www.youtube.com/watch?v=ktWcieiNzKs&ab_channel=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC