프로세스는 자원, 데이터, 그리고 스레드로 구성된다. 프로세스는 스레드가 운영 체제로부터 자원을 할당 받아 소스 코드를 실행하여 데이터를 처리하는데 이 때, 싱글 스레드 프로세스는 데이터에 단 하나의 스레드만 접근하므로, 문제될 사항이 없지만 멀티 스레드 프로세스의 경우, 두 스레드가 동일한 데이터를 공유하게 되어 문제가 발생할 수 있다.
아래의 코드는 하나의 객체 enemy의 두명의 플레이어(두개의 스레드)가 공격하여 쓰러트리게 되는 코드이다.
public class Main {
public static void main(String[] args) {
// 하나의 Enemy 객체를 공유하는 두개의 스레드 생성
Runnable enemyGetAttacked = new ThreadTask3();
Thread player1 = new Thread(enemyGetAttacked);
Thread player2 = new Thread(enemyGetAttacked);
player1.setName("Player1");
player2.setName("Player2");
player1.start();
player2.start();
}
}
class Enemy{
private int hp = 100;
public int getHp() {
return hp;
}
public boolean getDamaged(int damage){
// Enemy의 hp가 0 이상일때 플레이어로 부터 공격받을수 있다.
if(hp>0 && hp>damage){
try { Thread.sleep(1000); } catch (Exception error){}//스레드가 해당 객체에 접근한뒤 1초간 코드실행을 멈춤
hp -= damage; // 플레이어의 대미지 만큼 enemy의 hp가 줄어듬
return true;
}
// 즉 enemy의 hp가 0을 초과하면 플레이어에게 공격을 받고 0이하 일경우 enemy는 죽고 더이상 공격 받을 수 없어야 한다.
return false;
}
}
class ThreadTask3 implements Runnable {
Enemy enemy = new Enemy();
public void run() {
while (enemy.getHp()>0){
//플레이어의 공격데미지를 10~30까지 랜덤하게 정해줌
int playerAttackDamage = (int)(Math.random()*3+1)*10;
// 만약 enemy의 hp가 0이하가되어 죽어 있다면 true를 리턴
boolean killed = !enemy.getDamaged(playerAttackDamage);
// enemy가 현재 받은 데미지, 데미지를 준 플레이어의 이름, enemy의 현재 남은 hp, enemy의 죽음을 알리는 상태를 출력함
System.out.printf("Enemy got damaged %d By %s. Current HP : %d %s\n",
playerAttackDamage,
Thread.currentThread().getName(),
enemy.getHp(),
killed ? "-> already killed, cannot attack":"");
}
}
}
Thread.sleep(1000)을 준 이유는 하나의 스레드가 객체에 접근하고 있는 중에 운영체제의 판단으로 다른 스레드가 해당 객체에 접근하여 객체의 데이터에 직접적으로 영향을 주는 상황을 연출하기 위해 Thread.sleep()을 사용해 주었다.
출력물을 보면 연산 작용이 이상한 것을 알수 있다.
첫번째 데미지가 20이므로 남은 hp는 80이어야되고, 중간에 남은 hp가 20이므로 20의 데미지를 더 받앗을때 남은 hp가 0이 되면서 마지막 출력에는 "already killed, cannot attack" 이라는 메세지가 떠야 정상이다.
이런 출력의 이유는 첫번째 스레드가 접근한 상태에서 두번째 스레드가 들어와 데이터를 변경하고 그 변경된 데이터를 첫번째 스레드가 사용하면서 이상한 버그가 생긴 것이다.
이것을 해결해 주기위해 스레드 동기화를 적용해 줘야되며 스레드 동기화를 위해선 임계영역과 락에 대해 알아볼 필요가 있다.
임계 영역(Critical section)과 락(Lock)
임계 영역은 오로지 하나의 스레드만 코드를 실행할 수 있는 코드 영역을 의미하며, 락은 임계 영역을 포함하고 있는 객체에 접근할 수 있는 권한을 의미한다.
즉, 임계 영역으로 설정된 객체가 다른 스레드에 의해 작업이 이루어지고 있지 않을 때, 임의의 스레드 A 는 해당 객체에 대한 락을 획득하여 임계 영역 내의 코드를 실행할 수 있다.
이 때, 스레드 A가 임계 영역 내의 코드를 실행 중일 때에는 다른 스레드들은 락이 없으므로 이 객체의 임계 영역 내의 코드를 실행할 수 없다.
잠시 뒤 스레드 A가 임계 영역 내의 코드를 모두 실행하면 락을 반납하게되고 이 때부터는 다른 스레드들 중 하나가 락을 획득하여 임계 영역 내의 코드를 실행할 수 있다.
위의 예제에서 우리에게 필요했던 것은 두 스레드가 동시에 실행하면 안 되는 영역을 설정하는 것이니, getDamaged() 메서드를 두 스레드가 동시에 실행하지 못하게 해야 하면 문제를 해결 할 수 있다.
이를 임계 영역과 락이라는 용어를 사용하여 표현하면 "getDamaged() 메서드를 임계 영역으로 설정해야 한다." 라고 한다.
특정 코드 구간을 임계 영역으로 설정할 때에는 synchronized라는 키워드를 사용한다. synchronized 키워드는 두 가지 방법인 메서드 전체를 임계 영역으로 지정하거나 특정한 영역을 임계 영역으로 지정하여 사용할수 있다.
1. 메서드 전체를 임계 영역으로 지정하기
class Enemy{
...
public synchronized boolean getDamaged(int damage){
// 방법1. synchronized를 사용하여 메서드 전체를 임계 영역으로 지정하기
if(hp>0 || hp>damage){
try { Thread.sleep(1000); } catch (Exception error) {}
hp -= damage;
return true;
}
return false;
}
}
위의 코드와 같이 메서드의 반환 타입 좌측에 synchronized 키워드를 작성하면 메서드 전체를 임계 영역으로 설정할 수 있으며 이렇게 메서드 전체를 임계 영역으로 지정하면 메서드가 호출되었을 때, 메서드를 실행할 스레드는 메서드가 포함된 객체의 락을 얻어 해당 스레드만이 해당 메서드에 접근 할 수 있다.
2. 특정한 영역을 임계 영역으로 지정하기
class Enemy{
...
public boolean getDamaged(int damage){
synchronized (this) {
// 방법2. synchronized를 사용하여 특정 영역만을 임계 영역으로 지정하기
if (hp > 0 || hp > damage) {
try {
Thread.sleep(1000);
} catch (Exception error) {
}
hp -= damage;
return true;
}
return false;
}
}
}
위와 같이 synchronized 키워드와 함께 소괄호(()) 안에 해당 영역이 포함된 객체의 참조를 넣고, 중괄호({})로 블럭을 열어, 블럭 내에 코드를 작성하게 되면 특정 영역을 임계 영역으로 지정할 수 있게 된다.
이 경우에도 마찬가지로, 임계 영역으로 설정한 블럭의 코드로 코드 실행 흐름이 진입 할 때, 해당 코드를 실행하고 있는 스레드가 this 에 해당하는 객체의 락을 얻고, 독립적으로 임계 영역 내의 코드를 실행하게 된다.
최종 출력
최종 출력을 보면 정상적으로 작동하는 것을 확인 할 수 있다.
'Java > Java' 카테고리의 다른 글
스레드 (Thread) (0) | 2023.03.12 |
---|---|
자바 가상 머신(Java Virtual Machine) (0) | 2023.03.11 |
스트림의 메서드 (2) | 2023.03.11 |
스트림 (Stream) (0) | 2023.03.10 |
파일 입출력 (InputStream, OuputStream, FileReader, FileWriter, File) (0) | 2023.03.09 |