Java

[Java] 동기화

꽃달린감나무 2022. 3. 20. 18:48
728x90
  • 쓰레드의 메모리 접근방식

다음과 같이 존재한다고 가정해보겠습니다.

class IncreaseThread extends Thread{
	int num = 0;
    public IncreaseThread(int num){
		this.num = num;
    }
	public void run(){
		num +=1;
    }
}

public class ThreadAccessMemory{

	public static void main(String[] args){
    	int num = 9;

    	IncreaseThread threadA = new Thread(num);
    	IncreaseThread threadB = new Thread(num);
        
     	threadA.start();
        threadB.start();
	}

IncereaseThread : 변수 num을 증가시키는 쓰레드 

 

위 코드를 실행하면 어떤 값이 나올까요? 아마 예상가능한 값은 11일 것 입니다. 하지만 11이 아닌 다른 값이 나올 수 있습니다. 그 이유는 쓰레드의 메모리 접근 방식에 있습니다. 아래 그림을 보시죠!

쓰레드의 메모리 접근방식

왼쪽에서 오른쪽으로 진행되는 순서입니다. 

  1. threadA가 변수 num을 가져옵니다. num 값 현재 9입니다.
  2. threadA에서는 9를 1증가시키는 사이, threadB가 num에서 값 9을 가져옵니다.
  3. threadA에서 결과값 10값을 반환하게되고 num값은 10이 됩니다. 그 사이 ThreadB는 가져온 값 9를 10으로 증가시킵니다.
  4. threadB가 결과값 10를 num에 대입합니다.

원래 예상한대로 나오지 않는이유가 바로 위와 같습니다. 즉 num에 한 번에 한 쓰레드씩 접근하지 않고 두 개이상의 쓰레드가 접근하기 때문에 예상한 값 11이 나오지 않는 것이죠. 그럼 어떻게 해야할까요? 바로 변수 num에 한 번에 한 개의 쓰레드가 접근하게 되면,  쓰레드의 일이 끝날 때까지 다른 쓰레드가 접근하지 못하게해야 합니다. 그것이 바로 동기화입니다. 즉 동기화를 하게되면 아래 그림과 같이 됩니다.(동기화 처리라고 합니다. 보통 동기화 처리가 되면 Thread-safe하다라고 말은 합니다.)

 

동기화처리

  • 쓰레드 동기화 방법!
    • synchronized 기반의 동기화!

synchronized의 사용법은 간단합니다. 바로 메소드 선언할 때 붙여주기만 하면됩니다.

class Calculator{
	
   	int count = 0;
	public synchronized int add(int n1, int n2){ 
    	
        count++; 
        return n1 +n2;
    }
	public synchronized int minus(int n1, int n2){ 
    	
        count++;
    	return n1 - n2;
    }
}

 

위 처럼 synchronized를 선언해주면, 메소드가 동기화 처리가 된 것입니다. 이제 count의 값은 add와 minus 메소드가 호출된 수를 정확히 표기할 수 있습니다. 즉, 한 번에 한 개의 쓰레드만 접근이 한 번에 한 개만 가능합니다. 또한 add 메소드가 호출될 때, 다른 쓰레드가 minus 메소드 호출하는 것을 막을 수 있습니다. 이 반대의 경우도 마찬가지 입니다. 동기화 메소드에 의해서 메소드가 동기화되는 것이지만, 실질적인 동기화의 주체는 인스턴스이기 때문에 동기화되는 영역은 인스턴스 전체로 확장되기 때문입니다. 

좀 더 쉽게 설명하자면, 동기화처리를 하나의 자물쇠라고 생각해봅시다. 인스턴스는 동기화처리로 인해 자물쇠로 잠긴 집이 되고, 쓰레드는 이 집을 방문하려는 손님이 됩니다. 그럼 인스턴스는 손님에게 열쇠(열쇠는 하나만 존재한다.)를 주고, 손님에게 설명합니다. "이 집은 한 번에 한 명의 손님만 이용하실 수 있습니다. 사용하실 때 열쇠로 문을 잠가주시고 볼 일이 끝나시면 열쇠를 저에게 다시 반납해주세요" 그럼 손님(쓰레드)은 집 문을 잠그고 자신의 볼 일(메소드 호출 등)을 끝마친 뒤에야 열쇠를 집에게 반납하는 것입니다. 

 

흠...그럼 프로그램은 빠르게 작업을 해야하는데, 한 번의 한 쓰레드만 작업을 하게되면 너무 느리지 않을까..?

쓰레드 A가 add 메소드만을 필요로하고 쓰레드 B가 minus 메소드만을 필요로 한다면, 두 개가 동시에 호출 되면 더 좋은게 아닐까..? 맞습니다. 당연히 맞는 말입니다.  이 또한 방법이 있습니다.

 

  • 동기화 블록
public class IHaveTwoNum {

	Object key1 = new Object(); // 동기화 대상 num1의 열쇠
	Object key2 = new Object();// 동기화 대상 num2의 열쇠
	int num1 = 0;
	int num2 = 0;
	
	public void addOneNum1() {
		
		synchronized(key1){
			
			num1 +=1;
		}
	}
	public void addTwoNum1() {
		
		synchronized(key1){
			
			num1 +=2;
		}
	}
	
	public void addOneNum2() {
		
		synchronized(key2){
			
			num2 +=1;
		}
	}
	public void addTwoNum2() {
		
		synchronized(key2){
			
			num2 +=2;
		}
	}
	
	
}

위 코드처럼 sychronized의 동기화 블록을 이용해 동기화처리 대상을 동기화 할 수 있습니다. 이렇게 되면 addOne(Two)Num1, addOne(Two)Num2의 동기화 대상이 다르기 때문에, 서로 같은 순간에 호출이되도 문제가 없습니다. 따라서 각각의 잠금장치를 열 수 있는 key를 만들어 각자 접근할 수 있게 한 것입니다.(단, addOneNum1, addTwoNum1는 서로 동기화 대상이 같기 때문에 같은 열쇠를 써야합니다. num2와 관련된 메소드들도 마찬가지 입니다.) 

추가적으로, 동기화처리 해야하는 코드의 길이가 짧다면, 메소드를 전체를 동기화처리하는 것 보다 해당 블록만 동기화 처리하는 것이 처리속도가 훨씬 빨라집니다.

 

  • 동기화 실행순서 컨트롤 method
    • wait : 쓰레드를 잠시 대기시킵니다.
    • notify : 대기하는 쓰레드에게 실행시킵니다.
    • notifyAll : 대기하는 모든 쓰레드들은 실행시킵니다.
    • 해당 메소드들은 Object 클래스에 정의된 메소드들 이므로 어디서나 사용할 수 있습니다.
class NewsPaper{
	
	String todayNews;
	boolean isTodayNews = false;
	
	public void setTodayNews(String news) {
		todayNews = news; //뉴스가 들어왔다.
		isTodayNews = true; //뉴스가 들어옴을 표시!
		
		synchronized(this){
			notifyAll();  // 뉴스가 들어왔으므로 대기하고 있는 모든 쓰레드를 실행시킨다
		}
	}
	
	public String getTodayNews() {
		
		if(isTodayNews == false) { //오늘의 뉴스가 없다면,
			
			try {
				synchronized(this) {
				wait();  //글을 가져가려고하는 쓰레드를 대기시킨다.
				}
			}
			catch(InterruptedException e) {
				e.printStackTrace();
			}
		}
		return todayNews;
		
	}
	
}

위 코드를 쉽게 설명하자면, 앵커(쓰레드)가 방송을 위해 뉴스를 가져갈려고 할려는 상황입니다. 근데(isTodaynews =false)  뉴스가 없다면,   잠시 대기(wait)하고 있고, 뉴스가 들어오면(isTdaynews =true) 기자가 앵커에게 "뉴스가 들어왔으니 방송을 진행해(notifyAll)"라고 알립니다. 이처럼 메소드를 실행순서를 동기화시킬 수 있습니다.

 

  • synchronized 키워드 대체 가능한 ReentrantLock

동기화 하는 방법은 synchronized말고 다른 방법인 ReentrantLock입니다. 

(java.util.concurret.lock.ReentrantLock 패키지를 import해야합니다!!)

import java.util.concurrent.locks.ReentrantLock;

class IHaveTwoNum{
	int num1 = 0;
	
	public void addOneNum1() {
		key1.lock();  // 쓰레드가 들어오면서 lock을 겁니다.
		try {
			num1 +=1;
		}
		finally
		{
			key1.unlock(); //쓰레드가 볼일 본뒤 lock 해체합니다.
		}
	}
	
}

class AddThread extends Thread{
	
	IHaveTwoNum nums;
	
	public AddThread(IHaveTwoNum n) {
		nums = n;
	}
	
	public void run() {
		nums.addOneNum1();
	}
}

lock() 메소드를 통해 동기화 대상을 자신만 쓸 수 있게 자물쇠로 잠그고 , 사용을 완료했다면 다른 쓰레드가 쓸 수 있게 unlock() 메소드를 통해 자물쇠를 풉니다. (단, unlock 메소드는 어떤한 경우에도 반드시 실행되어야하기 때문에 try~finally문으로 사용하는 것을 추천드립니다.)  synchronized의 {}가 lock, unlock으로 대체되었다고 생각하시면 편하실 겁니다.

 

  • ReentrantLock  실행순서 컨트롤 메소드
    • await : 쓰레드를 대기시킨다. 
    • signal : 대기중인 하나의 쓰레드를 실행시킨다.
    • signalAll : 대기중인 모든 쓰레드를 실행시킨다.
    • 각각이 synchronized의 wait, notify, notifyAll 대응 됩니다.

좀 다른 사항이 있다면,

import java.util.concurrent.locks.ReentrantLock; //ReentrantLock이 포함되어이있는 패키지
import java.util.concurrent.locks.Condition; // signal, await 등의 메소드가 포함되어있는 패키지

두 패키지를 import 하는 것과 ReentrantLock의 인스턴스를 통해 await, signal 등의 메소드를 사용해야합니다.

사용법은 다음과 같습니다. 위와 비슷한 내용의 코드로 이 또한 문자열이 없으면, 대기하고 문자열이 생성되면 쓰레드를

호출해 문장을 가져가게 하는 것입니다. 반대로는 문자열이 아직 남아있다면 새로운 문자열은 가지고 온 쓰레드를 대기시키고, 문자열을 가져가는 쓰레드를 호출시켜 가져가게 한뒤 문자열을 저장하게 하는 것입니다.


import java.util.concurrent.locks.ReentrantLock;
import java.util.Scanner;
import java.util.concurrent.locks.Condition;

class StringComm{
	
	String newString;
	boolean isNewString = false;
	
	private final ReentrantLock entLock = new ReentrantLock();
	private final Condition readCond = entLock.newCondition();
	private final Condition writeCond = entLock.newCondition(); 
	// 문장을 읽을 때 사용하는 key readCond , 문자을 저장할 때 사용한 key writeCond를 entLock 대상으로 Condition 인스턴스 생성 	
	
	public void setNewsString(String news) { //새로운 문자열을 저장하기위한 메소드
		entLock.lock();//다른 인스턴스를 접근하지 못하게 함
		
		try {
			if(isNewString == true) { // 기존의 문장이 아직 존재한다면
				writeCond.await(); // writeCond를 기존의 문장을 가져갈 때까지 대기시킨다.
			}
			newString = news;  //새로운 문장을 저장
			isNewString = true; //새로운 문자열을 저장하면 안됨을 표시
			readCond.signal();//새로운 문자가 오는 것을 기달리는 쓰레드를 호출
		}catch(InterruptedException e) {
			e.printStackTrace();
		}
		finally {
			entLock.unlock(); // 다른 인스턴스가 접근가능하게 함
		}
	}
	
	public String getNewsString() { //새로운 문자열을 가져가기위한 메소드
		String retStr = null; 
		
		entLock.lock(); //다른 인스턴스를 접근하지 못하게 함
		try {
			if(isNewString == false) // 저장되어있는 문자열이 없다면
				readCond.await(); // 읽어오는 쓰레들 잠시 대기
			
			retStr = newString; // 새로이 문자열을 저장
			isNewString = false; // 새로운 문자열 저장되도 상관 없음을 표시
			writeCond.signal();//혹시 문자열을 쓰기위해 기달리고 있는 쓰레드를 호출
		}catch(InterruptedException e) {
			e.printStackTrace();
		}
		finally {
			entLock.unlock(); //// 다른 인스턴스가 접근가능하게 함
		}
		return retStr;
	}
}

class StringWriter extends Thread{
	
	StringComm comm;
	
	public StringWriter(StringComm comm) {
		this.comm = comm;
	}
	
	public void run() { 
		Scanner scan = new Scanner(System.in); 
		String writeStr;
		
		System.out.println("문장을 적어주세요!");
		writeStr = scan.nextLine(); //새로운 문자열을 입력받음
		comm.setNewsString(writeStr); // setNewsString 메소드를 통해 문자열을 저장
	}
}

class StringReader extends Thread{
	
	StringComm comm;
	
	public StringReader(StringComm comm) {
		this.comm = comm;
	}
	
	public void run() {
		System.out.println(comm.getNewsString());
	}
}
public class ConditionSyncStringReadWrite {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		StringComm comm = new StringComm();
		StringWriter wStr = new StringWriter(comm);
		StringReader rStr = new StringReader(comm);
		
		System.out.println("입출력 쓰레드 실력-^3^-");
		wStr.start();
		rStr.start();
	}

}
728x90