[Java] 스레드 동기화(Thread Synchronization)

Jeini·2023년 3월 16일

☕️  Java

목록 보기
56/72
post-thumbnail

💡 Thread Synchronization


✔️ Synchronization
: 여러 스레드가 한 리소스를 사용하려 할 때 사용하려는 스레드 하나를 제외한 나머지 스레드들은 리소스를 사용하지 못하도록 막는 것

✏️ 동기화 전

class UnsafeAccount {
	private int balance;
	
	public int getBalance() {
		return balance;
	}
	// 잔액 500원 일때
	public void deposit(int val) {
		balance += val;
	}
	
	public void withdraw(int val) {
		if(balance >= val) {
			try {
				Thread.sleep(500);
			} catch (InterruptedException e) {}
			balance -= val;
		}
		System.out.println("name : " + Thread.currentThread().getName() + ", balance = " + this.getBalance());
	}
}


public class UnsafeAccountSample {

	public static void main(String[] args) {
        // 공유자원: 객체 하나를 여러개의 쓰레드가 같이 씀
		final UnsafeAccount account = new UnsafeAccount();
		account.deposit(5000);
		
		Runnable withdrawRun = new Runnable() {
			
			@Override
			public void run() {
                // 10번 돌면서 500원씩 차감
				for(int i = 0 ; i < 10; i ++) {
					account.withdraw(500);
				}
			}
		};
		Thread t1 = new Thread(withdrawRun);
		Thread t2 = new Thread(withdrawRun);
		t1.start();
		t2.start();
	}
}
name : Thread-0, balance = 4000
name : Thread-1, balance = 4000
name : Thread-1, balance = 3500
name : Thread-0, balance = 3500
name : Thread-1, balance = 3000
name : Thread-0, balance = 2500
name : Thread-1, balance = 2000
name : Thread-0, balance = 1500
name : Thread-0, balance = 1000
name : Thread-1, balance = 500
name : Thread-1, balance = 0
name : Thread-1, balance = -500
name : Thread-1, balance = -500
name : Thread-1, balance = -500
name : Thread-1, balance = -500
name : Thread-0, balance = -500
name : Thread-0, balance = -500
name : Thread-0, balance = -500
name : Thread-0, balance = -500
name : Thread-0, balance = -500

if문을 통과하며 -500원이 출력되고 있는 것을 확인할 수 있다.

이렇듯, 멀티 스레드 프로그램에서는 여러 스레드들이 객체를 공유해서 작업하는 경우가 있다.
이때, 여러 스레드에서 동시에 한 객체에 접근하게 되면, 한 스레드에 의해 변경된 객체의 상태가 다른 스레드의 작업에 영향을 미쳐서 의도치 않은 결과를 낼 수 있다.

이러한 상황을 방지하기 위해 한 스레드가 사용 중인 객체를 다른 스레드에서 동시에 접근할 수 없도록 잠금을 걸어둘 수 있는 기능이 필요한데, 이것이 동기화(synchronized)이다.

📎 임계 영역

✔️ 멀티 스레드 프로그램에서 하나의 스레드 외에는 동시에 실행할 수 없는 코드 영역

public void withdraw(int val) {
		... 객체 상태 변경 => 임계 영역
}

자바는 임계 영역(Critical Section)을 지정하기 위해 동기화 메서드와 동기화 블록을 제공한다.

❗️ 상호배제(Mutex)

: 하나가 일을 끝마치기 전까지 다른 하나는 건드릴 수 없다.

💡 synchronized method


메서드의 제어자에 synchronized 키워드를 작성하여 임계영역을 설정할 수 있다.

synchronized 메서드가 호출되면 해당 메서드의 객체는 호출한 쓰레드에게 Lock Flag를 전달하며 해당 쓰레드 외에 공유객체를 필요로 하는 쓰레드는 Running 상태가 될 수 없으며 Lock Flag 가 반납될 때까지 Waiting Pool의 Lock pool에서 대기하게 된다.

✔️ 순서 요약
한 스레드가 동기화 메서드 및 동기화 블록에 들어감 ➡️ 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역의 코드를 실행하지 못하도록 함 ➡️ 해당 스레드가 동기화 메소드를 종료하면 즉시 잠금 해제됨 ➡️ 다른 스레드 실행

✏️ synchronized 활용

동기화 메서드는 메서드 선언 시, synchronized 키워드를 붙여서 만들 수 있다.
synchronized 키워드는 인스턴스와 정적 메소드 어디든 붙일 수 있다.

public synchronized void method() {

	//임계 영역

}

메서드의 일부 영역만 임계 영역으로 만들고 싶다면 다음과 같이 동기화 블록을 만들면 된다.

public void method() {

	//여러 스레드가 실행 가능한 영역
   synchronized(공유객체) {

   //임계 영역

 	}
}

동기화 블록의 외부 코드들은 여러 스레드가 동시에 실행할 수 있지만, 동기화 블록의 내부 코드는 임계 영역이므로 한 번에 한 스레드만 실행할 수 있다.

동기화 메서드와 동기화 블록이 여러 개인 경우, 한 스레드가 이들 중 하나를 실행 중일 때, 다른 스레드는 해당 메서드는 물론이고 다른 동기화 메서드 및 블록도 실행할 수 없다.

✏️ 동기화 후

class SyncAccount {
	
	private int balance;
	
	public synchronized int getBalance() {
		return balance;
	}
	
	public synchronized void deposit(int val) {
		balance += val;
	}
	
	public synchronized void withdraw(int val) {
		if(balance >= val) {
			try {
                // t1이 TIMED_WATING에 있을 동안 t2은 Blocked가 된다. 키를 t1이 갖고있기 때문. 둘다 실행 상태가 잠깐 비게 되는 것.
				Thread.sleep(500);
			} catch (InterruptedException e) {}
			balance -= val;
		}
		System.out.println("name : " + Thread.currentThread().getName() + ", balance = " + this.getBalance() );
	}
}


public class SyncAccountSample {

	public static void main(String[] args) {
		final SyncAccount account = new SyncAccount();
		account.deposit(5000);
		
		Runnable withdrawRun = new Runnable() {
			
			@Override
			public void run() {
				for(int i = 0 ; i < 10; i ++) {
					account.withdraw(500);
				}
			}
		};
		Thread t1 = new Thread(withdrawRun);
		Thread t2 = new Thread(withdrawRun);
		
		t1.start();
		t2.start();
	}
}
name : Thread-0, balance = 4500
name : Thread-0, balance = 4000
name : Thread-0, balance = 3500
name : Thread-0, balance = 3000
name : Thread-0, balance = 2500
name : Thread-0, balance = 2000
name : Thread-0, balance = 1500
name : Thread-0, balance = 1000
name : Thread-0, balance = 500
name : Thread-0, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0

account (this) 자체의 LOCK이 있다.
객체의 상태에 접근하는 녀석들을 같이 묶어 놓는다.

✏️ synchronized Lock 상태보기 예제

class Some {
	public synchronized void todo1() {
		try {
			System.out.println("inside todo1");
			Thread.sleep(5000);
			System.out.println("t2 state: " + ThreadEx.t2.getState());
			System.out.println("done : todo1");
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	
	public synchronized void todo2() {
		try {
			System.out.println("inside todo2");
			Thread.sleep(5000);
			System.out.println("t1 state: " + ThreadEx.t1.getState());
			System.out.println("done : todo2");
			
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}


public class ThreadEx {
	public static Thread t1;
	public static Thread t2;

	public static void main(String[] args) {
		final Some s =  new Some();
		t1 = new Thread() {
			@Override
			public void run() {
				s.todo1();
			}
		};
		t2 = new Thread() {
			@Override
			public void run() {
				s.todo2();
			}
		};
		
		t1.start();
		t2.start();

	}

}
inside todo1
t2 state: BLOCKED
done : todo1
inside todo2
t1 state: TERMINATED
done : todo2

t1이 Lock을 지고 들어간다 ➡️ 메서드에 들어가서 잠든다. ➡️ t2이 들어가게 되는데 t1이 나오질 않아 BLOCKED 에 빠진다. ➡️ t1이 메서드에서 나가고 Lock이 빠지면 그때서야 t2가 실행되게 되고 상태는 TERMINATED 로 전환된다.

💡synchronized block


✔️ 병목구간을 최소화 할 수 있고 구간 설정이 가능하다.
✔️ Lock 검사 대상을 설정할 수 있다. (서로 다른 멤버변수에 대한 연산)

인스턴스의 block단위로 lock을 건다. 이때, Lock객체를 지정해줘야한다.

❓ 사용 이유
동기화가 반드시 메서드 전체에 대해 이루어져야 하는 것은 아니다.
종종 메서드의 특정 부분에 대해서만 동기화하는 편이 효율적인 경우가 있다.
이럴 때는 메서드 안에 동기화 블록을 만들 수 있다.

✏️ 사용 예제

 public void add(int value) {

    synchronized(this) {
       this.count += value;   
    }
}

동기화 블록이 괄호 안에 한 객체를 전달받고 있다.
예제에서는 this (모니터 객체) 가 사용되었다. 이는 이 add() 메서드가 호출된 객체를 의미한다.

❗️ 모니터 객체(monitor object)
: 동기화 블록 안에 전달된 객체

이 코드는 이 모니터 객체를 기준으로 동기화가 이루어짐을 나타내고 있다.
동기화된 인스턴스 메서드는 자신(메서드)을 내부에 가지고 있는 객체를 모니터 객체로 사용한다.

같은 모니터 객체를 기준으로 동기화된 블록 안의 코드를 오직 한 스레드만이 실행할 수 있다.

✏️ 사용예제2

public class MyClass {
  
    public synchronized void log1(String msg1, String msg2) {
         log.writeln(msg1);
         log.writeln(msg2);
    }
    
    public void log2(String msg1, String msg2) {
         synchronized(this) {
            log.writeln(msg1);
            log.writeln(msg2);
         }
    }
}

한 스레드는 한 시점에 두 동기화된 코드 중 하나만을 실행할 수 있다.

여기서 두 번째 동기화 블록의 괄호에 this 대신 다른 객체를 전달한다면, 동기화 기준이 달리지므로 스레드는 한 시점에 각 메서드를 실행할 수 있다.

📎 static 메서드 내의 synchronized block

두 메서드는 각 메서드를 가지고 있는 클래스 객체를 동기화 기준으로 잡는다.

public class MyClass {

   public static synchronized void log1(String msg1, String msg2) {
        log.writeln(msg1);
        log.writeln(msg2);
   }

  
    public static void log2(String msg1, String msg2) {
       synchronized(MyClass.class) {
            log.writeln(msg1);
            log.writeln(msg2);  
       }
    }
}

같은 시점에 오직 한 스레드만 이 두 메서드 중 어느 쪽이든 실행 가능하다.

두 번째 동기화 블록의 괄호에 MyClass.class 가 아닌 다른 객체를 전달한다면, 쓰레드는 동시에 각 메서드를 실행할 수 있다.

💡 static synchronized Method


✔️ 인스턴스가 아닌 클래스 단위로 Lock이 발생한다.
✔️ 동일한 클래스의 static synchronized 메서드 내에서 하나의 스레드만 실행

✏️ 사용 예제

public static MyStaticCounter {
    private static int count = 0;

    public static synchronized void add(int value) {
        count += value;
    }
}

동기화된 static 메서드가 속한 클래스의 클래스 개체에서 동기화된다.

Java VM에는 클래스당 하나의 클래스 객체만 존재하므로 동일한 클래스의 static synchronized 메서드 내에서 하나의 스레드만 실행할 수 있다.

✏️ 사용 예제2

public static MyStaticCounter {
    private static int count = 0;

    public static synchronized void add(int value) {
      	count += value;
    }

    public static synchronized void subtract(int value) {
      	count -= value;
    }
}

클래스에 둘 이상의 static synchronized 메서드가 포함된 경우, 메서드 내에서 동시에 하나의 스레드만 실행할 수 있다.

즉, 스레드 A가 subtract() 를 실행 중인 경우, 스레드 B는 스레드 A가 종료될 때까지 add() 를 실행할 수 없다.

✏️ producer-consumer 문제


✔️ wait(), notifyAll() 응용

멀티스레드 환경에서는 synchronized 키워드를 사용하여 동기화를 하더라도 고려해야 할 것들이 많이 있다.

그중 하나가 producer-consumer 문제이다.

예를들어 상품(MyBox)를 공급해주는 생산자(producer)와 물건을 소비하는 소비자(consumer)가 있을 경우, 생산자는 상품이 이미 마켓에 가득 찬 경우에 추가로 물건을 공급할 수 없기 때문에 문제가 생길 수 있고, 소비자는 마켓에 물건이 하나도 없을 경우에 상품을 소비할 수 없기 때문에 문제가 생길수 있다.

이러한 경우에 적절한 동기화 처리를 하기 위해서 wait() 와 notify() , notifyAll() 메서드가 사용될 수 있다.

📎 wait()

✔️ 갖고 있던 고유 Lock을 해제하고, 스레드를 잠들게 함

Lock을 소유한 Thread가 자신의 제어권을 양보하고 WAITING 또는 TIMED_WAITING (일시 정지)상태에서 대기하기 위해서 사용된다.

📎 notify(), notifyAll()

✔️ notify()
: 잠들어 있던 스레드 중 임의로 하나를 골라 깨움

✔️ notifyAll()
: 호출로 잠들어 있던 스레드 모두 깨움

wait상태에 빠져있는 다른 Thread를 다시 RUNNABLE 상태(실행 대기 상태)로 변경시키는 역할을 한다.

❗️ 위 메서드의 공통점

  • 호출하는 스레드가 반드시 고유 Lock을 갖고 있어야 한다. 다시 말해, synchronized 블록 내에서 호출되어야 한다.

  • 고유 Lock을 획득하지 않은 상태에서 위 메서드들 중 하나를 호출하면 IllegalMonitorStateException 가 발생한다.

  • 또한 유의해야 하는 점은 이들은 Thread의 메서드가 아니라 Object의 메서드라는 점이다.


✏️ 응용해보기

📌 상품이 가득 찬 경우

  • 마켓에 물건이 가득 찼을 경우에는 생산자(producer)가 더이상 생산하지 않고 기다리게 한다.
    ➡️ wait()
  • 소비자(consumer)가 물건을 소비한 후에 생산자(producer)에게 알려준다.
    ➡️ notifyAll()

📌 상품이 없을 경우

  • 마켓에 물건이 없을 경우에는 소비자(consumer)가 더이상 소비하지 못하고 기다리게 한다.
    ➡️ wait()
  • 생산자(producer)가 물건을 생산한 후에 소비자(consumer)에게 알려준다.
    ➡️ notifyAll()
class MyBox {
	private int contents;
	private boolean isEmpty = true;
	
	public synchronized int get() {
		while(isEmpty) {
			try {
				wait();
			} catch (InterruptedException e) {}
		}
		isEmpty = !isEmpty;
		notifyAll();
		System.out.println(Thread.currentThread().getName() + " : 소비 " + contents);
		return contents;
	}
	
	public synchronized void put(int value) {
		while(!isEmpty) {
			try {
				wait();
			} catch (InterruptedException e) {}
		}
		contents = value;
		System.out.println(Thread.currentThread().getName() + " : 생산 " + value) ;
		isEmpty = !isEmpty;
		notifyAll();
	}
}

class Consumer extends Thread {
	private MyBox box;
	public Consumer(MyBox c) {
		box = c;
	}
	public void run() {
		int value = 0;
		for(int i = 0; i < 10; i++) {
			box.get();
			try {
				sleep(100);
			} catch (InterruptedException e) {}
		}
	}
}

class Producer extends Thread {
	private MyBox box;
	
	public Producer(MyBox box) {
		this.box = box;
	}
	
	public void run() {
		for(int i = 0; i < 20; i++) {
			box.put(i);
			try {
				sleep(100);
			} catch (InterruptedException e) {}
		}
	}
}


public class ProducerConsumer {

	public static void main(String[] args) {
		MyBox c = new MyBox();
		Producer p1 = new Producer(c);
		Consumer c1 = new Consumer(c);
		Consumer c2 = new Consumer(c);
		
		p1.start();
		c1.start();
		c2.start();
	}
}

References
: https://yolojeb.tistory.com/11
: https://jenkov.com/tutorials/java-concurrency/synchronized.html

profile
Fill in my own colorful colors🎨

0개의 댓글