이전 포스팅에서는
공유 객체를 향한 스레드의 경쟁이
적은 경우의 상황을 알아 보았다.
이번에는 공유객체를 향해 스레드의 경쟁이 심해지는 경우, 어떤 방식으로 JVM이 스레드 동기화를 시도하는지 파헤쳐 볼 것이다. 결론부터 말하면, 경쟁이 심해지면 공유 객체는 Heavy Weight Lock 상태가 된다. Heavy Weight Lock 상태는 모니터(Monitor) 방식으로 상호배제를 구현한다
Monitor 방식은 다음 포스팅에서 진행할 예정이다. 이번 포스팅은 Monitor 방식이 나올 수 있도록 영감을 준 세마포어(Semaphore) 방식에 대해서 알아볼 것이다.
JVM은 세마포어 방식을 사용하지 않는다. JVM는 synchronized 키워드를 통한 Monitor방식으로 상호배제를 구현한다. JVM이 Monitor 방식을 사용하지만, JAVA 표준 API 라이브러리 안에는 세마포어 클래스도 존재한다.
직접 코드로 상호배제를 하는 경우, API로 제공되는 클래스를 사용해주어야 한다. 일반적인 코딩으로는 상호배제를 할 수없기 때문이다. 그 이유는 알고리즘상에는 상호배제가 일어나도, 하드웨어 영역에서는 상호배제가 이루어지지 않기 때문이다.
상호배제 알고리즘인 피터슨 알고리즘은 상호배제가 정확히 수행되지 않음을 이전 포스팅에서 알아보았다. 간단한 한 줄의 코드라도 여러 개의 어셈블리어 명령으로 나뉘어진다. 그러므로 스레드를 검사하고 컨텍스트 스위칭되는 하드웨어 영역 상의 과정에서 스레드는 인터럽트되어 상호배제가 깨질 수 있다. 그러므로 일련의 과정은 다른 스레드가 개입될 수 없는 원자성이 확보된 Operation으로 이루어져야 한다. 이와같은 원자성을 부여하는 Operation을 제공하는 방법이 세마포어(Semaphore)이다.
세마포어(Semaphore)
세마포어는(Semaphore)는 한국말로 '신호기'라는 의미다.
기찻길의 신호기를 생각해보자. 두 개의 철도가 하나의 철도로 합쳐진다면 신호기가 필요하다. 두 개의 철도는 하나의 철도를 '공유'하므로 그 위로 지나가는 두 기차도 하나의 철도를 공유하기 때문이다. 두 기차의 충돌을 막으려면, 기차 간의 진입 순서를 정해야한다. 그 역할을 신호기(Semaphore)가 한다.
그 신호기의 역할을 Semaphore 생성자의 파라미터로 들어가는 < int permits >이 한다. 이가 어떻게 활용되는지 알아보자. 임계영역(critical section), 즉 공유된 철도에 접근하려면 권한(lock)을 얻어야한다. 권한을 얻으려면 우선 임계영역이 점유된 상태인지부터 확인해야한다. 이때 점유상태 여부를 알려주는 flag역할을 하는 것이 파라미터로 받은 permits 값이다. int permits의 값으로 1을 넣어주었다고 가정해보자.
생성자의 파라미터로 받은 1의 의미는 공유자원이 1개라는 의미다. 이제부터 세마포어 객체 안에서 1이 저장된 변수를 S라고 하겠다. 만약 누군가가 한 개의 자원을 점유하면 S는 0이 될 것이다. 그리고 점유를 끝내고 공유자원을 내놓으면 다시 S는 1이 될 것이다. 공유자원에 접근한 스레드는 S가 1이면 자원을 점유한다. 그러나 S가 0(혹은 음수)이면 공유자원이 점유 중이라는 뜻이므로, 스레드는 대기 상태가 된다.
대기상태에 들어간 스레드는 List로 된 자료구조에 저장된다. 일종의 '대기실'인 것이다. 자원이 사용가능해지면, List에서 자고 있는 스레드 중 하나를 깨워야 하는데, 그 선택 방법은 무작위다. 그러나 만약 boolean fair 파라미터에 true 값을 넣어주면 first in first out방식을 선택하여, 제일 처음 리스트에 들어갔던 스레드를 실행시킨다.
세마포어 클래스의 메소드들이다. 이 중에서 가장 기본적으로 알아야하는 부분은 acquire()와 release()다. 메소드 이름에서도 알 수 있듯이, acquire()는 lock을 확보하는 Operation을 실행하는 메소드이고, release()는 lock을 내려놓는 Operation을 실행하는 메소드이다.
스레드가 접근하여 acquire()가 호출되면 세마포어는 S를 검사한다. S가 1이면 스레드가 임계영역으로 들어가는 것을 허가해주고, 만약 1이 아니라면(1보다 작으면) 접근한 스레드를 리스트(List) 자료구조에 넣어 저장한 후 대기(일시정지) 시킨다.
공유자원을 점유한 스레드는 임계영역에서의 활동을 끝내고, 공유자원을 내려놓는다.(S + 1) 이때 release()가 사용된다. release()는 자원을 내려놓고 대기 리스트에서 대기 중인 스레드들 중 하나를 깨운다. 깨어난 스레드는 임계영역에 들어간다.
이 과정을 코드를 통해 확인해보자.
<Main 클래스>
package semaphore;
import java.util.concurrent.Semaphore;
public class Main {
static final int LOOP = 10000; // 각 스레드 반복 접근 횟수
public static void main(String[] args) {
// TODO Auto-generated method stub
Semaphore s = new Semaphore(1,true); //세마포어 객체생성 (permit = 1 : 공유자원 1개, fair = true : FIFO)
Account account = new Account(s); // 공유객체 생성
//스레드 생성
Thread depositThread1 = new Thread(new Deposit(account));
Thread withDrawThread1 = new Thread(new WithDraw(account));
Thread depositThread2 = new Thread(new Deposit(account));
Thread withDrawThread2 = new Thread(new WithDraw(account));
//스레드 이름 설정
depositThread1.setName("지훈 입금");
withDrawThread1.setName("지훈 출금");
depositThread2.setName("민정 입금");
withDrawThread2.setName("민정 출금");
//스레드 실행
depositThread1.start();
withDrawThread1.start();
depositThread2.start();
withDrawThread2.start();
//스레드 정지
try {
depositThread1.join();
withDrawThread1.join();
depositThread2.join();
withDrawThread2.join();
}catch(InterruptedException e) {}
}
//잔액 출력
account.printBalance();
}
세마포어 객체를 생성해주고 공유객체는 세마포어 객체를 파라미터로 갖는다. 그래야 공유 객체는 세마포어를 통한 동기화 작업을 할 수 있다.
<공유 객체 Account 클래스>
package semaphore;
import java.util.concurrent.Semaphore;
public class Account {
private int balance =0; // 잔액
Semaphore s; // 세마포어 객체 참조변수
public Account(Semaphore s) { // 생성자
this.s = s;
}
public void deposit(int money) {
try {
s.acquire(); // 세마포어 객체를 통한 동기화 검사
// 임계 영역(critical section)
System.out.println(Thread.currentThread().getName() + " : " + money+"원");
balance += money;
System.out.println("현재 잔액 : " + balance+"원");
System.out.println();
s.release(); // Lock 해제
}catch(InterruptedException e) {}
}
public void withDraw(int money) {
try {
s.acquire(); // 세마포어 객체를 통한 동기화 검사
//임계영역
System.out.println(Thread.currentThread().getName() + " : " + money+"원");
balance -= money;
System.out.println("현재 잔액 : " + balance+"원");
System.out.println();
s.release(); // Lock 해제
}catch(InterruptedException e) {}
}
// 계좌 속 잔액 출력
public void printBalance() {
System.out.println("현재 잔액 : "+ balance);
}
}
<입금 스레드>
package semaphore;
public class Deposit implements Runnable{
Account account; // 공유객체
public Deposit(Account account) {
this.account = account;
}
@Override
public void run() {
for(int i =0; i<Main.LOOP;i++) {
account.deposit(10); // Main.Loop 만큼 접근하여 10원 입금
}
}
}
<출금 스레드>
package semaphore;
public class WithDraw implements Runnable {
Account account; // 공유 객체
public WithDraw(Account account) {
this.account = account;
}
@Override
public void run() {
for(int i =0; i<Main.LOOP;i++) {
account.withDraw(10); // 10원 입금
}
}
}
Main을 실행하면, 총 잔액은 0이 나와야한다. 입금 스레드 2개, 출금 스레드 2개가 똑같이 10000번을 접근하여, 10원을 입금하고 10원을 출금하기 때문이다. 만약 상호배제가 깨져, 공유자원이 훼손된다면 0원이 나오지 않게 된다.
한번 출력을 진행해보자.
<출력결과>
상호배제가 정확히 이루어졌음을 알 수 있다. 이처럼 세마포어는 여러개의 스레드가 1개의 공유객체에 접근하여 데이터를 사용할 때, 공유객체 안에서 여러 스레드의 동기화 작업을 수행한다.
Counting Semaphore
세마포어의 S가 1인 경우는 Binary Semaphore라고 한다. 그럼 S가 2개 이상인 경우는 어떻게 할까? 이런 경우를 Counting Semaphore라고 한다. 간단히 예를 들어 설명해보겠다.
사무실에 프린터가 3대가 있다. 직원 A가 프린터 한 대를 점유하면 2대가 남으니, 직원 B는 직원 A가 프린터를 내려놓을 때까지 대기 할 필요없이, 2대 중 한 대를 점유한다. 그 후, 직원 C가 남은 프린터 한 대를 점유한다. 이렇게 되면 모든 프린터는 점유된 상태가 된다. 이 상황에서 다른 직원이 프린터를 사용하려면 남은 프린터가 없으니 대기해야한다.
이처럼 Couting Semaphore는 공유 자원이 1개가 아닌 2개 이상인 경우에서 사용하는 상호배제 기법이다. 이를 코드를 통해 알아보자.
코드는 위 사이트를 참고하여 만들어 보았다.
< 공유 객체, Library 클래스 >
package countingsemaphore;
import java.util.concurrent.Semaphore;
public class Library {
private final int MAX_PERMIT = 3; // S는 3, 공유 자원 3개
private Book[] books = {new Book("데미안_1"), new Book("데미안_2"), new Book("데미안_3")}; //도서관에 데미안 책이 총 3권 있다.
private boolean[] beingRead = new boolean[MAX_PERMIT];// 데미안 3권의 책의 대출여부를 알려주는 배열
private Semaphore s = new Semaphore(MAX_PERMIT,true); // 세마포어 객체 생성 (Counting Semaphore)
// 책 대출하기
public Book checkOut() throws InterruptedException {
s.acquire(); // 대출이 가능한지 세마포어 동기화 검사
//가능하면 임계영역 접근, 불가능하면 대기
//------------ 임계영역 시작 -------------//
return getAvailableBook(); // 대출 메소드 호출
}
//대출 메소드
public Book getAvailableBook() {
Book book= null;
for(int i =0; i<MAX_PERMIT;i++) {
if(!beingRead[i]) {//데미안 3권 중 이용가능한 책 탐색
beingRead[i] = true; // true를 넣어 '대출중'으로 바꿈
book = books[i]; // 해당 책의 객체주소 획득
System.out.println(Thread.currentThread().getName() + "님 대출 완료 : " +book.getName());
break;
}
}
return book; // 대출한 책의 객체 주소 획득
}
//책 반납하기
public void returnBook(Book book) {
if(getAvailable(book)) { // 책 대출가능 상태로 돌려놓는 메소드 호출
s.release(); // Lock 해제 공유 자원 내려놓기
//--------------------- 임계영역 끝 -------------------------//
}
}
// 책 대출가능 상태로 돌려놓기
public boolean getAvailable(Book book) {
for(int i=0; i<MAX_PERMIT; i++) {
if(book == books[i]) { // 내가 대출했던 책 탐색
beingRead[i] = false; //false를 넣어 반납되어 대출가능한 상태임을 표시
System.out.println(Thread.currentThread().getName()+ "님 반납완료 : " + book.getName());
break;
}
}
return true; // 완료
}
}
< 공유 객체에 접근하는 스레드, Reader 스레드 >
package countingsemaphore;
public class Reader implements Runnable {
Library library;
Book book;
public Reader(Library library) {
this.library = library; // 공유 객체 주소 획득
}
@Override
public void run() {
try {
book = library.checkOut(); // acquire() 호출
System.out.println(Thread.currentThread().getName() + "님이 독서 중입니다.");
Thread.sleep(1000);
library.returnBook(book); // release() 호출
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
< Book 클래스 >
package countingsemaphore;
public class Book {
private String name;
public Book(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
<Main 클래스>
package countingsemaphore;
public class Main {
public static void main(String[] args) {
// TODO Auto-generated method stub
Library library = new Library(); // 공유객체 도서관 생성
Thread[] reader = new Thread[10]; // 접근 스레드 회원 10명 생성
for(int i = 0; i < reader.length; i++) {
reader[i] = new Thread(new Reader(library)); // 회원 10명 생성
reader[i].setName("회원" + (i+1)); //회원 스레드 이름 설정
reader[i].start(); // 회원 스레드 실행
}
try {
for(int i = 0; i<reader.length; i++) {
reader[i].join(); // 회원스레드 일시정지
}
}catch(InterruptedException e) {}
}
}
<출력 결과>
.
빨간 상자를 확인해보자. 회원1, 회원2, 회원3이 책을 대출하고 책을 읽고 있으면 회원 5, 회원 6, 회원 7은 대출을 못하고 있다가, 회원 1, 2, 3이 반납을 완료하자, 회원 5,6,7이 대출을 했음을 알 수 있다.
도서관에 데미안 책이 총 3권 있으니 S를 3으로 설정한 결과이다. ( 위 코드에서는 상수 MAX_PERMIT으로 3을 설정하여 사용해주었다.) 3개의 자원이 모두 점유되면 나머지 스레드들은 대기하는 상태가 된다.
Semaphore s = new Semaphore(3,true);
이와 같이, 세마포어 객체를 생성하여 사용해주면, Counting Semaphore 상호배제가 가능해진다. 다음 포스팅에서는 실제로 JVM이 동기화를 위해 채택하고 있는 상호배제 방법인 Monitor방식에 대해서 다루어보겠다.
정리
1. 하드웨어 영역상의 상호배제를 위해 세마포어가 사용된다.
2. JVM은 세마포어가 아닌 모니터 방식의 상호배제를 채택하여 사용한다.
3. 공유 자원이 1개일 때는 Binary Semaphore를 사용한다.
4. 공유 자원이 2개 이상일 때는 Counting Semaphore를 사용한다.
참고자료
https://www.concretepage.com/java/java-counting-and-binary-semaphore-tutorial-with-example
https://www.youtube.com/watch?v=nezpmbe6Tkg&t=1997s
https://docs.oracle.com/javase/7/docs/api/
https://parkcheolu.tistory.com/28
'JAVA > JAVA Basic' 카테고리의 다른 글
[ JAVA ] 스레드(Thread) 동기화4 ( yield(), join() ) (0) | 2021.06.20 |
---|---|
[ JAVA ] 스레드(Thread) 동기화3 ( 모니터(Monitor) ) (2) | 2021.06.20 |
[ JAVA ] 스레드(Thread) 동기화1 ( Intrinsic Lock + 피터슨 알고리즘 ) (2) | 2021.06.20 |
[ JAVA ] 스레드(Thread) 우선순위 설정 (1) | 2021.06.20 |
[ JAVA ] 스레드(Thread)의 생성 (0) | 2021.06.20 |