JAVA/JAVA Basic

[ JAVA ] 스레드(Thread) 동기화1 ( Intrinsic Lock + 피터슨 알고리즘 )

IT록흐 2021. 6. 20. 09:04
반응형

동기화

 

지훈이네 가족은 아빠, 엄마 그리고 형까지 네 식구로 이루어져 있다. 그러나 집 안에 컴퓨터가 단 한 대 뿐이라 가족간의 충돌이 불가피했다. 그래서 지훈이네 가족은 컴퓨터를 쓰는 순서를 정하기로 결정했다.

 

이것이 '동기화'다. 동기화는 한정된 자원을 두고 작업들 간의 작업순서를 정하는 것이다. 작업순서를 정할 때는 두 가지 개념을 생각해야 한다.

 

1. 상호배제(Mutex)

2. 협동(Cooperation)

 

지훈이가 컴퓨터를 쓰고 있는 중에, 형이 나타나 컴퓨터를 낚아채면 안 된다. 이것이 상호배제(Mutex)다. 그리고 지훈이가 컴퓨터를 다 썼으면 다음 차례에게 컴퓨터를 다 사용했음을 알려줘야한다. 이것이 협동(Cooperation)이다. 상호배제와 협동이 잘 구현되면 안정적인 자원공유가 가능해진다. 그럼 동기화를 JAVA를 통해 알아보자.

 

 

Implicit(내포된) Lock vs Explicit(밖으로 드러난) Lock

 

 

JAVA는 기본적인 상호배제 방법으로 synchronized 키워드를 이용한다.

 

public synchronized void add(){}

 

위와 같이, 메소드에 synchronized 키워드가 있으면, JVM이 알아서 해당 객체에 Monitor 상호배제 방식을 적용한다. 이를 두고 Implicit Lock이라 부른다. Implicit Lock은 Intrinsic Lock이라고도 불린다.

 

Intrinsic Lock이란 고유락이라는 의미로, Obeject 마다 하나씩 갖고 있는 객체 헤더의 Mark Word를 이용하여, JVM이 상호배제를 하는 방법이다. (앞으로 자세히 알아볼 것이다.)

 

Itrinsic Lock 방식은 synchronized 키워드 하나로 JVM이 알아서 상호배제를 해주기 때문에, 스레드간의 작업순서가 정밀하게 조절될 수가 없다. 한마디로 상호배제는 정확히 일어나는데, 상호배제 안에서 필요한 협동(Cooperation)이 세밀히 일어나지 못하여, 특정 스레드들은 전혀 모니터에 접근하지 못하는 공평성(fairness)의 문제가 생기기도 한다.

 

그래서 JAVA에서는 java.util.concurrent.locks 클래스를 제공하여, JVM이 알아서 모든 것을 처리하는 것이 아닌, 구체적인 많은 부분들을 조작할 수 있는 Locking 방식을 제공한다. 이를 Explicit Lock이라 부른다.

 

이번 포스팅에서는 Intrinsic Lock 방식에 대한 구체적인 것들을 살펴볼 예정이다.

 

공유 객체

 

JAVA에서 한정된 자원은 '객체'이다. STACK 영역에 스레드들이 생성되면, 스레드들은 각자 자기 일을 하며 HEAP 영역에 있는 객체들을 참조한다. 그러다보면 2개 이상의 스레드가 동시에 한 객체를 참조하는 경우가 발생한다.

 

분명 그림 그리기를 시작할 때는 흰색 물감이 있었는데, 그림을 그리는 중 어떤 애가 들어와 흰색물감을 다 써버린 경우와 같다. 결국 원하는 색깔로 그림을 그리지 못하게 된다.

 

이처럼 객체에 2개 이상의 스레드가 동시에 접근하면 원하는 조건의 결과를 얻지 못한다. 그러므로 개발자는 동시접근이 일어나는 객체에 '동기화' 작업을 해주어야 한다.

 

 

 

객체를 생성하면 객체는 헤더를 갖는다. 헤더는 Lock 정보를 담는 부분과 클래스 정보를 담는 부분으로 나뉜다. 클래스 부분은 클래스의 정보를 담고 있어, 메소드와의 동적바인딩 할 때 자주 이용된다.

 

 

 

[ JAVA ] 상속의 원리 : 메소드 동적 바인딩

메소드 오버라이딩을 공부하면서 궁금했다. 상속받은 메소드와 오버라이딩 된 메소드는 어떻게 구분이 되는 것일까? 출처 입력 메소드 호출 코드이다. Car car = new Car(); car.run(); 참조 변수 + .(도

lordofkangs.tistory.com

 

 

관련해서는 해당 포스팅에서 다루어보았다. 동기화를 할 때는 Lock 정보를 담고 있는 부분이 사용된다. 작업순서를 결정하는 원리는 간단하다. 스레드가 객체에 접근했을 때, 객체가 잠겨 있는지(lock) 열려 있는지(unlock) 확인하는 것이다.

 

객체가 잠겨있으면, 이미 다른 스레드가 해당 객체의 메소드를 사용하고 있어 진입을 막아 놓은 것이다. 이처럼 객체의 lock 상태를 알 수 있는 헤더부분을 Mark Word라고 부른다.

 

Mark Word는 32bit나 64bit로 구성되어 있다.

 

unlock 상태의 Mark Word

 

Hash Code는 객체의 고유번호를 의미한다. 그러므로 객체가 unlock 상태이면, Mark Word는 객체의 고유정보를 담고 있다는 의미다. 하지만 동기화가 시작되면 Mark Word의 비트들은 바뀌는데, 어떻게 바뀌는지는 스레드들간의 경쟁(contended)의 정도에 따라 결정된다.

 

Biased Lock

 

대부분의 경우, 경쟁이 없는 경우가 많다. 그 중에서도 동기화된 객체에 한 가지 스레드만 반복하여 접근하는 경우가 많다. 그러므로 똑같은 스레드에 대한 동기화 작업의 반복은 CPU 낭비가 된다. 그래서 Mark Word는 해당 스레드 전용 Mark Word로 바뀌는데 이를 두고 Biased Lock이라 부른다.

 

Biased Lock 상태의 Mark Word

 

 

Biased 비트는 1로 바뀌어 해당 Mark Word는 Biased Lock임을 나타내는 flag의 역할을 한다. 그리고 HashCode는 해당 스레드의 ID로 바뀐다. 그러므로 스레드가 Lock을 획득하기 위해 재접근을 했을 때, 해당 스레드가 Thread ID와 일치하다면 별다른 동기화 검사 없이, 손쉽게 Lock을 획득할 수 있다.

 

 

Light Weight Lock

 

하지만 만약 해당 스레드가 아닌 다른 스레드가 접근을 시도한다면 Baised Lock 상태는 깨져버린다. 이런 경우, Mark Word의 상태는 Light Weight 상태로 전환된다. 이는 지훈이만 컴퓨터를 사용하고 있었는데, 형이 갑자기 나타나, 지훈이한테 "언제 끝나?"하며 닥달하고 있는 상황이다. 즉 공유 객체를 향한 경쟁이 증가한 것이다. 이런 경우, Mark Word는 Light Weight 상태로 업그레이드 된다.

 

LightWeight Lock 상태의 Mark Word

 

객체의 고유정보를 담은 Mark Word는 사리지고 Lock Record라는 곳의 주소(Address)가 공간을 차지하고 있다. 그럼 Lock Record는 어디일까? 객체의 Lock을 획득한 스레드는 자신의 Stack 메모리에 객체의 고유정보가 담긴 Mark Word를 복사한다.

 

 

 

Mark Word를 복사한 후, Mark Word의 주인(Owner)의 주소도 저장시켜 놓는다. 이렇게 생성된 Lock Record가 Owner를 통해 객체를 가리키고, 객체의 Mark Word가 Lock Record가 저장된 스레드의 STACK을 가리키면, 해당 스레드는 Lock을 확보한 것이다.

 

Spin Lock

 

Light Weight Lock 에서 Lock을 확보하지 못한 스레드는 어떻게 될까? 대기해야한다. 대기하는 스레드는 Spin Lock 상태가 된다.

 

Spin Lock 상태가 되면 객체가 unlock 상태가 될 때까지 스레드는 의미없는 루프를 계속 돈다. 이 의미없는 루프는 Lock을 확보한 스레드가 Lock을 내놓을 때까지 지속된다. 매반복마다 대기하는 스레드는 공유 객체의 메소드가 unlock 상태가 되었는지 확인한다.

 

그렇다면 왜 계속 무한루프를 도는 것일까? 스레드가 실행되려면 CPU의 코어를 할당받아야 한다. 할당받지 못한 스레드는 wait 상태가 된다. wait상태에서 CPU를 할당받으면 CPU 코어는 이전 스레드의 자원에서 해당 스레드에 맞게 자원을 새로이 저장시켜놓는다. 이 과정을 컨텍스트 스위칭(Context Switching) 이라 부른다. 컨텍스트 스위칭은 오버헤드를 발생시킨다. 그러므로 무한 루프를 계속 돌면서 CPU코어를 놓지 않는 것이다. CPU코어를 놓아 Wait상태가 되면, 다시 CPU코어를 할당 받았을 때, 필요한 데이터를 CPU에 다시 저장시켜야 하는 컨텍스트 스위칭이 발생하기 때문이다.

 

물론 CPU를 아무런 생산적인 활동없이 무한루프를 돌면서 차지하고 있는 것도 일종의 손해다. 하지만 Spin Lock을 도는 스레드가 적고 코어의 개수가 많다면 컨텍스트 스위칭보다는 훨씬 이득이 될 수 있다.

 

이렇게 CPU를 차지하며 무한루프를 돌며 대기하던 스레드가 공유 객체가 unlock 상태가 된 것을 확인하면, 대기하던 스레드는 공유 객체의 Mark Word 복사 과정을 거쳐, Lock을 확보한다. 이 과정에서 CAS operation이 작동한다.

 

CAS Operation

 

Compare and Swap(CAS) Operation은 '하드웨어적인 상호배제'를 보장한다. 하드웨어적인 상호배제가 무슨 말이냐면, 공유 객체가 unlock 상태가 되면, 대기하던 스레드는 반복을 풀고 Mark Word를 복사하는 과정을 거쳐 lock을 확보한다. 이 같은 알고리즘은 논리적 봤을 때, 상호배제가 일어나는 것처럼 보인다. 하지만 대기하던 스레드가 unlock 조건을 확인 후, Lock을 확보하는 과정은 하드웨어적으로 atomic하게 일어나지 않는다.

 

다시 말하면, 간단한 한 줄의 코드라 할지라도, 로우 레벨로 내려가면 여러개의 어셈블리어 명령으로 나뉘어진다는 얘기다. 그 말 즉, 조건을 확인하고 Lock을 확보하는 과정 안에서 Lock을 다른 스레드에게 찬탈 당할 수 있다는 얘기다. 이를 실제 코드로 확인해보자

 

피터슨 알고리즘(Peterson's algorithm)

 

상호배제의 대표적인 알고리즘인 피터슨 알고리즘을 이용하여 공공재 생산과 소비 프로그램을 구현해보았다.

 

공공재를 생산하는 스레드와 소비하는 스레드가 있다. 공공재를 생산하면 공공재의 개수(count)를 + 1 해준다. 반대로 공공재를 소비하면 공공재의 개수(count)를 -1 해준다.

 

생산과 소비 스레드가 공공재 개수(count) 변수에 동시에 접근하면, count는 뒤죽박죽될 것이다. 그러므로 생산 스레드와 소비스레드가 서로 번갈아가며 접근할 수 있도록 피터슨 알고리즘으로 '동기화' 해주었다. 처음에 0개였던 공공재가 생산이 이루어지면 1개, 그 다음 소비가 이루어지면 다시 0개가 된다. 이 과정이 2n번 반복되면 결국 총량은 0개가 되어야 한다.

 

하지만 상호배제의 대표적인 알고리즘인, 피터슨 알고리즘을 통해 구현해 보았을 때, 결과는 다르게 나온다.

 

package peterson;

public class Main2 {
	
	static int count=0; // 공공재 갯수
	static int turn=0; // 현재 실행 중인 스레드 (생산 스레드는 0 소비 스레드는 1)
	static boolean[] flag = new boolean[2]; // 스레드 lock확보 상태표시 ( flag[0] 은 생산, flag[1]은 소비 )

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		Thread pthread = new Thread(new Producer()); // 공공재 생산 스레드
		Thread cthread = new Thread(new Consumer()); // 공공재 소비 스레드
		
		pthread.start(); // 공공재 생산 스레드 시작
		cthread.start(); // 공공재 소비 스레드 시작
		
		try {
			pthread.join(); // 공공재 생산 스레드 정지
			cthread.join(); // 공공재 소비 스레드 정지
			
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} 
		System.out.println("공공재 남은 갯수 : " + count); // 공공재 총량 출력
	}
	
	// 공공재 생산 스레드
	static class Producer implements Runnable{ 

		@Override
		public void run() {
			
			for(int i =0;i<10000;i++) {

                ////////// 스레드 대기 //////////////

				flag[0] = true; //Lock 확보대기 
				turn =1;
                 // 생산 스레드(0)는 대기, 소비스레드(1)는 공유 자원에 접근 중, 그러므로 현재 턴은 소비스레드 턴 :  turn = 1;
				while(flag[1]&&turn==1); // Spin Lock (소비스레드가 false로 바뀌기를 기다리며 무한루프)
				
                ///////////////////////////////////

                ////////// 임계 영역(공유 자원) //////////////////	
				
                count++; // Spin Lock이 풀리면 count 공유 자원에 접근
				
                //////////////////////////////////////
				
                 /////////// Lock release /////////////
                flag[0] = false; // flag를 false로 바꾸어 소비스레드 Spin Lock 풀어 접근허용
					
			}
			
		}
	}

   // 공공재 소비 스레드
   static class Consumer implements Runnable {

		@Override
		public void run() {
			
			for(int i =0; i<10000; i++) {
				
				flag[1] = true;
				turn = 0;
				while(flag[0]&&turn==0);
				
				count--; // 공유자원
				flag[1] = false;
				
			}
			
		}
	}
	
}

< 출력 결과 >

 

.

 

생산 스레드와 소비 스레드를 각각 10000 번씩 번갈아 실행시켜본 결과 count의 개수가 0이 아닌 -2가 나왔다. 한 마디로 10000번의 반복 과정 속에서 상호배제가 깨졌다는 이야기다. 결국 count 공유 데이터는 훼손되고 말았다.

 

상호배제가 깨진 이유는 간단하다. 이론상(알고리즘)으로는 완벽하지만 실제 하드웨어적으로는 상호배제가 깨진 것이다.

 

        // Lock 확보 전 스레드 대기
		flag[0] = true; //Lock 확보대기 
		turn =1; // 생산 스레드(0)는 대기, 소비스레드(1)는 공유 자원에 접근 중이니 turn = 1;
		while(flag[1]&&turn==1); // Spin Lock (소비스레드가 끝나 false 대기를 기다림)
					

 

Lock을 확보하기 전, 대기하는 스레드가 데이터를 초기화하고 Spin Lock을 하며, 조건을 확인하는 과정은 Low Level의 관점에서 보았을 때, 다양한 명령어로 이루어진 복잡한 과정이다. 이 말은 즉슨, Lock을 확보하는 과정 안에서 다른 스레드가 비집고 들어갈 틈이 많아, Lock 확보를 찬탈 당할 가능성이 있다는 말이다. 한마디로 상호배제가 깨지는 것이다.

 

그러므로 멀티스레딩을 할 때 가장 중요한 것은 Lock을 확보하는 과정의 '원자성(Atomicity)을 보장하는 것이다. Lock을 확보하는 과정에서 다른 스레드의 개입을 막아주는 개념인 것이다. 이 때 사용되는 원자성을 보장해 주는 Operation System이 CAS(Compare and Swap) Operation이다. 어떻게 해서 원자성을 보장해주느냐? 하는 문제는 시스템에 대한 이해가 필요하기 때문에 아직 나는 잘 모르겠다.

 

하지만 이같은 CAS Operation의 개념을 이용하여 원자성을 보장해주는 타입을 JAVA에서는 제공해준다.

 

JAVA API 문서

 

 

 

 

해당 타입으로 선언된 값들은 갱신되는 과정에서 원자성이 확보된다. 다시말하면 다른 스레드가 개입할 수 없다는 의미다. 이를 이용하여 위 코드를 다시 짜보자.

 

package peterson;

import java.util.concurrent.atomic.AtomicBoolean; 

public class Main {
	
	static int count=0;
	static int turn=0;
	static AtomicBoolean[] flag = new AtomicBoolean[2]; //boolean => AtomicBoolean 
	static {
		
		for(int i =0; i<flag.length;i++) {
			flag[i] = new AtomicBoolean(); 
            //boolean과 다르게 원시타입이 아니므로 new를 통해 객체 생성해준다.
		}
	}

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		Thread pthread = new Thread(new Producer());
		Thread cthread = new Thread(new Consumer());
		
		pthread.start(); 
		cthread.start();
		
		try {
			
			pthread.join();
			cthread.join();
			
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} 
		
		System.out.println("공공재 남은 갯수 : " + count);
	}
	
	static class Producer implements Runnable{

		@Override
		public void run() {
			
			for(int i =0;i<10000;i++) {
				flag[0].set(true); // true 세팅
				turn =1;
				while(flag[1].get()&&turn==1); //flag값 가지고 오기
					
				
				count++; // 공유 자원
				System.out.println("생산"); // 생산 스레드 접근시 출력
				
				flag[0].set(false); //false 세팅
					
			}
		}
	}

 static class Consumer implements Runnable {

		@Override
		public void run() {
			
			for(int i =0; i<10000; i++) {
				
				flag[1].set(true);
				turn = 0;
				while(flag[0].get()&&turn==0);
				
				count--; // 공유자원
				System.out.println("소비");
				
				flag[1].set(false);
				
			}
			
		}
	}
	
}

 

<출력 결과>

 

 

 

이와 같이, Lock을 확보하는 과정에서 원자성이 확보되자, 서로 상호배제를 깨뜨리지 않고 번갈아가면 count에 접근하였다. 그리고 공공재 남은 갯수도 0개로 count 공유 자원이 훼손되지 않음을 확인 할 수 있다.

 

그럼 여기서 스레드간의 경쟁이 더 심화되면 어떻게 될까?

 

Light Weight Lock에서 대기하는 스레드는 Spin Lock을 돌기 때문에 CPU의 코어는 죄다 대기하는 스레드로 점유되어질 것이다. 이런 사태를 막고자, 2개보다 많은 스레드가 공유자원을 놓고 경쟁을 하게 되면, 공유 객체의 Mark Word는 Heavy Weight Lock상태로 바뀐다. 여기서는 '모니터(Monitor)'라는 방법이 사용된다.

 

'Heavy Weight Lock'은 다음 포스팅에서 진행하겠다.

 


정리

 

1. 공유자원을 두고 서로의 순서를 정하는 것이 '동기화'이다.

 

2. 동기화는 상호배제(mutex)와 협동(cooperation)으로 이루어진다.

 

3. JAVA에서 경쟁없이 한 스레드만 반복해서 공유객체에 접근하면, 객체는 Biased Lock 상태로 한 가지 스레드의 전용 상태로 바뀐다.

 

4. Biased Lock 상태에서 경쟁이 추가되면, 객체는 Light weight Lock 상태로 전환된다.

 

5. Light Weight Lock 상태에서 대기하는 Lock은 무한 루프를 도는 Spin Lock 상태가 된다.

 

6. 한 스레드가 Lock 내놓고 다른 스레드가 Lock을 얻는 과정은 알고리즘상의 상호배제 뿐만 아니라, 하드웨어적인 상호배제도 이루어져야한다. 그러므로 CAS operation을 통한 Lock 확보의 원자성을 보장해주어야 한다.

 

 


 

참고 자료

 

https://happy-coding-day.tistory.com/8

 

https://www.logicbig.com/tutorials/core-java-tutorial/java-multi-threading/java-intrinsic-locks.html

 

https://m.blog.naver.com/PostView.nhn?blogId=bumsukoh&logNo=110143451645&proxyReferer=https:%2F%2Fwww.google.com%2F

 

https://bestugi.tistory.com/40

 

https://howtodoinjava.com/java/multi-threading/multithreading-difference-between-lock-and-monitor/

 

https://www.programmersought.com/article/407747922/

 

https://www.youtube.com/watch?v=CtTYNh0M9Vw&list=PLHqxB9kMLLaOs2BM2KbuvttBYCgDoFm-5&index=14

 

 

반응형