https://www.youtube.com/watch?v=ktWcieiNzKs 

 

Why 리뷰?

멀티스레드와 동기화에 대한 이론을 Java로 구현해본 경험이 없어서 찾아보게되었다.

 

영상보기전 떠오르는 생각정리

멀티스레드와 동기화에 대한 개념을 생각했을때 가장 먼저 떠오르는것은, 대학교 3학년 시절에 배웠던 운영체제와 Unix다.

이번 영상의 주제는, 운영체제에서 Producer/Consumer 문제와 비슷한 궤를 유지한다고 생각하는데, 

Producer/Consumer 문제의 핵심은, 여러 스레드가 동시에 공유자원에 접근한다면, 어떤 데이터가 손실될 수 있다. 라는 것이다. 

 

이러한 운영체제의 개념을 Unix 수업에서 배웠었던 Semaphore를 활용하여 해결할 수 있었다.

OS에서 제공했던 함수들은 다음과 같다.  

Include 정보.
#include <sys/sem.h>
#include <sys/types>
#include <sys/ipc.h>

1. int semget(key_t key, int nsems, int semflg) : 새로운 세마포어 집합을 생성하거나 기존의 세마포어 집합에 연결합니다.
key: 세마포어 식별자를 생성하기 위한 키 값.
nsems: 세마포어 집합에 포함될 세마포어의 수.
semflg: 세마포어의 플래그. 주로 IPC_CREAT, IPC_EXCL과 함께 사용하여 세마포어를 생성하거나 이미 존재하는지 확인합니다.

2. semctl(int semid, int semnum, int cmd, ...) : 세마포어 제어 함수로 여러 명령(cmd)들을 수행할 수 있다. 
세마포어 값의 설정, 초기화, 제거 등을 합니다.
semid: 세마포어 식별자.
semnum: 세마포어 집합 내에서 조작하려는 세마포어의 인덱스 번호.
cmd: 수행할 명령. 주요 명령으로 SETVAL (값 설정), IPC_RMID (세마포어 삭제), GETVAL (값 조회) 등이 있습니다.
나머지 인자는 cmd에 따라 다르게 사용됩니다.


3. semop(int semid, struct sembuf *sops, size_t nsops) : 세마포어 연산 함수로, 세마포어의 값을 조절하기 위해 사용됩니다.
semid: 세마포어 식별자.
sops: 세마포어 연산을 정의한 sembuf 구조체 배열의 포인터.
nsops: sops 배열의 크기, 즉 세마포어 연산의 수.
sembuf 구조체는 sem_num (세마포어 인덱스), sem_op (연산 값), sem_flg (플래그)로 이루어져 있습니다.

4. p(sem) or wait(sem) : 세마포어 감소연산, 세마포어 값이 0보다 작으면 대기

5. v(sem) or signal(sem) : 세마포어 증가연산.

예시)
p(sem);					//wait
WORKING WORKING WORKING;	//critical section
v(sem);					//signal

조금 다른길로 샌것같기도하지만, Java 기반에서 멀티스레드와 동기화를 구현한다고한다면, 위의 Semaphore와 비슷할 것이라 생각하여 한번 정리해보앗다.

 

 

영상정리시작

영상 주제 : 멀티스레드 환경에서 발생할수 있는 동시성 이슈를 Java로 어떻게 Trouble Shooting 해야할까? 라는 영상이라고 한다.

 

 

영상목차

  1. 공유자원, 임계영역, 경쟁상태
    1. Read-Modify-Write 예시
    2. check-then-act 예시
  2. 원자성과 가시성이란
  3. 동기화방법
    1. 블로킹
      1. Syncrhonized 키워드 사용
    2. 논블로킹
      1. Atomic Pattern 사용
  4. 스레드 안전한 객체 설계 방법

 

1. 공유자원과 임계영역

공유자원: 여러 스레드가 동시에 접근할 수 있는 자원이다.

임계영역: 공유자원들 중 여러 스레드가 동시에 접근했을때 문제가 생길 수 있는 부분이다.

경쟁상태 : 둘이상의 스레드가 공유자원을 병행적으로 읽거나 쓰는 동작을 할떄 타이밍이나 접근 순서에 따라 결과가 달라지는 것.

 

1-1) READ-Modify-WRITE 로 인한 오류 예시로 아래의 코드를 들었다.

수강강의가 열렸을때 30명 이상이면 폐강된다고 한다.

// Read-Modify-Write 예시, 시간텀이 발생하기에 값이 잘못갱신됩니다. 
//경쟁조건을 피하기 위해 READ-MODIFY-WRITE를 하나의 연산으로 보장시켜야합니다. A 스레드가 B 스레드의 연산에 관여하지 않게 됩니다.
// Question. 만약 30명의 수강생이 동시에 신청하게 된다면??
// Answer : 3o개의 요청이 한번에 들어왔기에 결과값이 30이어야할텐데, "/1/count" 호출시 28이 나온다.
@RestController
@RequestMapping("/race-condition")
public class RaceConditionController{
	public static Integer studentCount = 0;
    @PostMapping("/1/increase")
    public ResponseEntity<Void> increaseCount(){
    	studentCount++;
        return ResponseEntity.ok().build();
    }
    
    @GetMapping("/1/count")
    public ResponseEntity<Integer> getCount(){
    	return ResponseEntity.ok(studentCount);
    }
    
}

1. 수강인원 30명이 동시에 수강신청한다면?  count = 28 명인데 수강폐강되었다.

관련 로직을 자세히 살펴보면, 수강신청 버튼을 누르면,

변수의 값 READ - 변수의 값 Modify - 변수의 값 쓰기 Write가 실행된다 ( 멀티스레드에서 이것이 맞물리면, 서로 수강신청값이 WRITE가 되면서 값이 올바르게 안바뀌기에 30명이 다 들어가지 않는 것이다. )

 

 

1-2) check - then - act 패턴의 오류 예시로 아래의 코드를 들었다.

오류상황 : 수강신청 인원이 30명아래면 폐강위험이 출력되고 있는상황. 하지만, 실제로는 30명을 넘어선 숫자가 출력되며 폐강위험이 나오고 있다.

//Check-then-act 예시
//Questio. 수강신청 후에 숫자를 세서 30명 미만이면 폐강위험경고문을 출력하는 메소드를 동시에 100명이 요청한다면?
//Answer : 30명 이상의 값이 출력된다.
//Reason : 서로 다른 쓰레드가 studentCount를 계속해서 올려주기 떄문.
PostMapping("/2/check-then-act")
public ResponseEntity<Void> printWarning() throws InterruptedException{
	studentCount++;
    if(studentCount < 30){
		Thread.sleep(1);
        System.out.println("폐강위험, StudentCount = "+studentCount);
    }
    return ResponseEntity.ok().build();
}

원인 ㅡ :Thread 1번에서 if 분기문 통과 전에는 30명 이전이었으나, 그 이후에 Thread2에서 더해지면서 30이 넘는것이다.

 

해결방안은 원자성과 가시성을 보장하자.

원시성이란 ? 공유자원에 대한 작업의 단위가 더이상 쪼갤 수 없는 하나의 연산인것처럼 동작하는것이다.

즉, 한 Thread가 연산을 할떄, 다른 Thread의 연산이 개입할 수 없도록 설정한다.

 

아래는 가시성에 대한 미사일 예시이다. 

//Question. 가시성에 대한 이해. CPU Cache에 대한 이해

public class NoVisibility{

	public static boolean missileLaunched = false;
    
    private static class MissileInterceptor extends Thread{
    	@Override
        public void run(){
        	while (!missileLaunched) { /* 대기 */ }
            System.out.println("요격");
        }
    }
    
    public static void main(String[] args) throws InterruptedException{
    	final MissileInterceptor missileInterceptor = new MissileInterceptor();
        missileInterceptor.start();
        Thread.sleep(5000);
        launchMissile();
        missileInterceptor.join();
    }
    
    private static void launchMissile() { missileLaunched = true; }

}

코드의 상황 설명

 

1. 미사일 A 변수 : missileLaunched = false 인 상태로 시작한다.

2. 미사일 A는 5초동안 기다리다가 missileLaunched = true로 설정하여 발사한다.

3. 미사일 A가 발사되면 미사일 A를 요격하기 위한 미사일 B가 발사되는데, 이떄 missileLaunched=true로 변했음에도 해당 변수의 변화를 인식하지 못한다.

 

왜일까? Thread 가 서로 다른 CPU Cache를 바라보고 있기 떄문이다. Main Thread와 Thread1, Thread2 는 각각 서로 다른 CPU Cache를 바라보고 있다. 

왜 각각의 Thread는 서로 다른 CPU Cache를 보는것일까에 대한 정리

1. Thread를 실행하는것은 CPU 이다.

2.  메인메모리에서 변수값(missileLaunched)을 읽어와서 Thread를 실행한다.

3. Main Memory와 CPU 간의 거리가 멀어서 Thread1, Thread2 는 CPU Cache를 사용한다. Thread의 연산은 CPU Cache에 모든 연산을 반영한뒤 Main meory에 담는 방식으로 진행한다.

4. 코드로보면, 먼저 Main Meory의 missileLaunched 값(false)을 CPU Cache 에서 가져가서 missileLaunched = false 인상태로 CPU Cache에 담게된다. 그리고 Main Thread의 CPU Cache는 현재 missileLaunched는 false인 값이다.

5. 그다음 MissileInterceptor Thread가 Main memory에 있는 false값을 가져가게 되고, CPU Cache에 담습니다. 3번에서 말했지만, 서로 다른 CPU Cache를 바라보고 있다.

6. 그 이후에 main Thread에서 CPU Cache값의 missileLaunched=false 값을 true로 변경시키며 Main Memory의 missileLaucnhed=true 로 변경된다. 하지만, Thread1, Thread2 ... 에서 바라보고있는 CPU Cache는 missileLaunched값이 MainMemory값을 바라보지못하여 인식하지 못한다.

 

즉, 가시성을 챙기지 못하는것이다. 가시성은 여기서 서로 같은것을 바라보지 못한다는 의미임을 알 수 있다. 

 

간단한 해결방안으로는, 가시성을 챙기기 위해 missileLaunched에 volatile 이라는 키워드를 넣어주면 missileLaunched는 무조건 Main Memory를 통해서만 조회함으로써 해결가능하다.

//Question. 가시성에 대한 이해. CPU Cache에 대한 이해

public class NoVisibility{

	public volatile static boolean missileLaunched = false; //메인메모리에서만 값을 읽고 쓰는 작업을 시킵니다. CPU Cache를 사용하지 않습니다.
    
    private static class MissileInterceptor extends Thread{
    	@Override
        public void run(){
        	while (!missileLaunched) { /* 대기 */ }
            System.out.println("요격");
        }
    }
    
    public static void main(String[] args) throws InterruptedException{
    	final MissileInterceptor missileInterceptor = new MissileInterceptor();
        missileInterceptor.start();
        Thread.sleep(5000);
        launchMissile();
        missileInterceptor.join();
    }
    
    private static void launchMissile() { missileLaunched = true; }

}

 

동기화

이제 원자성과 가시성을 보장하는 동기화 방법에 대해 알아본다는 뜻이다.

블로킹 방식 2개. 논블로킹 방식 1개에 대하여 알아본다.

1) 블로킹 방식의 Monitor 메커니즘, Synchronize 키워드가 Monitor 메커니즘을 사용하고 있다.

2) Non-Blocking 방식의 Atomic Pattern

 

정말 많은 방식이 존재하지만 위 2가지만 살펴본다.

1) 블로킹 방식으로 Monitor 메커니즘 사용. Synchronized 키워드

특정 스레드가 작업을 수행하는동안 다른 작업은 진행하지 않고 대기하는 방식이다.

키워드로는 EX) Monitor, Synchronized 키워드가 있다.

 

Montior 메커니즘에 대한 설명

1. 모니터라는 메카니즘에는 배타동기큐, 임계영역, 조건동기큐가 존재한다. 임계영역은 한개의 스레드만 들어가도록 설계가 되어있다.

2. 임계영역에 들어가려면 한 스레드가 작업을 실행하다가 wait() 라는 연산이 실행되면 이 스레드는 sleep 상태가 되면서 조건동기큐로 이동한다.

3. 대기중이던 (배타동기큐에있는) 임계영역이 비어있기에 다른 스레드로 임계영역에 들어가서 작업한다. 만약 임계영역에서 작업을 진행하다가 NotifyAll()을 호출한다면, 조건동기큐에 있는 스레드가 다시 임계영역으로 들어와서 작업을 하는방안이 Monitor 패턴이다.

 

추가 설명 : 배타동기는 synchronized로 설정. 조건동기는 wait(), notify(), notifyAll() 의 함수가 존재. 조건동기큐는 임계영역에서 밀려나서 이동하는것.

 

 

//Check-then-act 예시
//Questio. 수강신청 후에 숫자를 세서 30명 미만이면 폐강위험경고문을 출력하는 메소드를 동시에 100명이 요청한다면?
//Answer : 30명 이상의 값이 출력된다.
//Reason : 서로 다른 쓰레드가 studentCount를 계속해서 올려주기 떄문.
PostMapping("/2/check-then-act")
public synchronized ResponseEntity<Void> printWarning() throws InterruptedException{
	studentCount++;
    if(studentCount < 30){
		Thread.sleep(1);
        System.out.println("폐강위험, StudentCount = "+studentCount);
    }
    return ResponseEntity.ok().build();
}

1. Synchronized , 배타동기를 선언하는 키워드로 Synchronized 키워드 제공 , 연산결과가 메모리에 써질떄까지 다른 스레드는 임계영역에 들어올 수 없는 기능을 제공한다.

2. 예시로, 수강신청 30명 미만이면 폐강위험 출력하는것에 Synchronized를 붙여서 하면 진행하면 수강신청인원을 무조건 1개의 스레드로만 임계구역에서 작업하기에 순서대로 1부터 29까지 찍힌다. 

3. 어떻게 이렇게 된것인지 정리. 임계구역에 한개의 스레드만 들어올 수 있기에 이 스레드가 다 들어오고 나서 연산을 모두 다한 후 메인메모리에 반영시킨 이후에 임계구역에서 나온다.즉 studentCount를 1개씩만 더할 수 있는것이다. 그러면 다른스레드가 들어오록 대기한다. 새로 스레드가 들어올떄는 메인메모리에서 동기화된 값을 가져오기에 문제가 없다. 순차접근을 하며, 원자성 + 가시성을 보장한다.

 

문제점

- 하지만 이러한 블로킹 synchronized에는 단점이 존재. 나머지 스레드는 대기하기에 성능저하가 발생.

- 임계구역에 들어갈떄 Lock 을 획득하고 들어가기 때문에 DeadLock이라는 문제가 발생가능.

- DeadLock이란? 하나의 형제가 약속시간이 겹쳐서 옷을 입고나가야하는데 형은 티셔츠만 입고있고, 동생은 바지만 입고있는 상태. 서로 자기가 잡고있는 자원을 놓지않고 상대방이 놓을떄까지 기다리는 상태임.

 

DeadLock 문제점을 Synchronzied로 해결하는 예시.

public abstract class Cloth{
	public abstract void wear();
}

public class Tshirt extends Cloth{
	@Override
    public void wear(){
    	System.out.println("상의를 입음");
    }
}

public class Pants extends Cloth{
	@Override
    public void wear(){
    	System.out.println("바지를 입음");
    }
}

public class DeadLockEx {
	public static Cloth tShirt = new TShirt();
    
    public static CLoth pants = new Pants();
    
    public void wearTshirtThenWearPants(){ //Tshirt에 대한 락을 획득하고, TShirt를 입은뒤, Pants에 대한 락을 획득하고, Pants를 입습니다.
    	synchronized(tShirt){
        	wearCloth(tShirt); 
            try{
            	Thread.sleep(100);
            } catch (InterruptedExcption ignored){}
            Synchronized (pants){
            	wearCloth(pants);
            }
        }
    }
    
    public void wearPantsThenWearTshirt(){
    	synchronized(pants){
        	wearCloth(pants);
            try{
            	Thread.sleep(100);
            } catch (InterruptedException ignored) {}
            synchronized(tShirt){
            	wearCloth(tShirt);
            }
        }
    }
    
    public void wearCloth(final Cloth cloth){
    	cloth.wear();
    }
}



class DeadLockExTest{
	@Test
    @DisplayName("한 명은 상의를 입고 한명은 바지를 입고 서로 상대방 옷을 입으려 해서 데드락을 발생시킵니다.")
    void deadlock() throws InterruptedException{
    	final ExecutorService executorService = Executors.newFixedThreadPool(2); //2개의 스레드를 생성하여, 티셔츠를 먼저 입는 메소드와 바지를 입는 메소드 실행
        final CountDownLatch countDownLatch = new CountDownLatch(2);
        final DeadLockEx deadLockEx = new DeadLockEx();
        
        executorService.submit(() -> {  //TShirt 먼저 입는 경우
        	deadLockEx.wearTshirtThenWearPants();
            countDownLatch.countDown();
        });
        executorService.submit(() -> { //Pants를 먼저 입는경우
        	deadLockEx.wearPantsThenWearTshirt();
            countDownLatch.countDown();
        });
        
        //위의 2개의 스레드가 실행된 이후에, await 문을 걸어줘서 awa
        countDownLatch.await(10000, TimeUnit.MILLISECONDS); //10초를 기다려서 통과해야 성공처리합니다.
        
        if(countDownLatch.getCount() == 0){ //10초 이후에 0 으로 넘어왔다면, 데드락이 발생하지 않은것입니다.
        	throw new RuntimeException("데드락이 발생하지 않았습니다.");
        }
        
        //올바르게 락을 걸어줌으로써 데드락이 발생하지 않았습니다.
    }
    
}

 

 

 

Non-BLocking 방식, Atomic 패턴

다른 스레드의 작업여부와 상관없이 자신의 작업을 수행하는 방식, EX) Atomic 타입, 

1. CAS 라는 알고리즘이 필요. Compare And Set 이라는 알고리즘이다. 

2. 자원값 - 기대값 - 새로운값 

  2-1. 만약 자원값과 기대값이 같으면, 기존 자원값을 새로운 값으로 수정하고 True 반환. 

  2-2. 자원값과 기대값이 다르다면 수정하지 않고 False 반환, FALSE 이후의 반환 처리에는 개발자의 처리에 따라 달라진다.

 

Atomic 타입에 대한 본격적인 정리

- 동시성을 보장하기 위해서 자바에서 제공하는 Wrapper Class

- CAS(Compare and set) + Volatile을 활용해서 원자성과 가시성을 보장한다.

 

1. 일반적인 스레드에서는 값을 연산하고자할때 JVM과 CPU 사이에 있는 CPU Cache값에서 변수를 끌어온다.

2. Atomic Reference를 설정하게 되면, 내부에 Volatile이 박혀있어서 JVM Memory에서 바로 가져오는것이다. 

public class AtomicReference<V> implements Serializable {
	private static final long serialVersionUID = -L값;
    private static final VarHandle VALUE;
    private volatile valule //volatile이 존재하여 JVM Memory에서 바로 Thread로 사용할 수 있습니다.

3. 연산을 할떄 compareAndSet() 알고리즘이 실행된다. 메모리에 저장된 값과 스레드 내부 기대값 비교. 만약에 일치하면 true, 아니면 false를 반환하는 식으로 동작한다.

4. Atomic Reference가 Volatile을 통해서 가시성을 보장하고, CAS를 통해서 원자성을 보장합니다.

//AtomicInteger를 적용. 원시성과 가시성을 보장
//Answer : 30명 값이 출력된다.
public class AtomicController{
	private AtomicInteger studentCount = new AtomicInteger(0);
    
    @PostMapping("/increase")
    public ResponseEntity<Void> increaseAtomicCount(){
    	studentCount.addAndGet(1);
        return ResponseEntity.ok().build();
    }
    
    @GetMapping("/count")
    public ResponseEntity<AtomicInteger> getStudentCount(){
    	return ResponseEntity.ok(studentCount);
    }
}

 

 

스레드 안전한객체란?

여러 스레드가 동시에 클래스를 사용하려하는 상황에서 클래스 내부의 값을 안정적인 상태로 유지할 수 있다.

공유변수를 두는 순간 신경써야하는 포인트가 많아진다.

스레드 안전한 객체를 설계하는법

1. 스레드한정

2. 블로킹 큐

3. 자바 모니터 패턴

4. 락

5. 인스턴스 한정

6. 상태범위 제한

7. 조건 큐

8. ThreadLocal

9. Volatile

10. 위임기법

11. Producer-Consumer 패턴

12. 동기화 컬렉션

 

가장 확실한방법 하나. 

공유 변수 최소화, 캡슐화 작업이 필요하다.

관리해야하는 포인트를 캡슐화 시켜서 그 객체만 다룰 수 잇는것이 중요하다.

 

 

추가로 읽어보면 좋을 글 JPA에서 낙관적 락 사용하는것.

https://jaehhh.tistory.com/148

 

JPA에서 낙관적 락을 적용해보자

문제이슈 내편(내 마음을 편지로) 서비스에서 롤링페이퍼의 메세지에 좋아요를 누를 수 있는 기능이 있습니다. - 메세지 좋아요에 엄청난 연타를 누르거나 - PC, 모바일에서 좋아요를 동시에 눌

jaehhh.tistory.com

 

+ Recent posts