두 달만에 쓰는 쓰레드 이야기…
참고 자료는 주로 자바 튜토리얼을 썼습니다. 해석이 안되는 부분은 Power Java라는 책을 읽었고요.
정확히 말하면 Concurrency: 동시성에 관련된 이야기 인데, 개요부터 시작하자면, 여러분이 보통 사용하는 프로그램은, 예로 워드를 들면, 키보드와 마우스 입력을 받으면서, 맞춤법 체크도 하고, 정해진 시간마다 자동으로 저장도 해주는 등의 여러가지 일을 동시에 처리 하죠? 하지만 이 때까지 여러분이 짠 코드는 대부분 한 번에 한 가지 일밖에 못했습니다.
하지만 위의 워드와 같은 concurrent software를 만들기 위해 자바에서는 이와 관련된 API를 지원합니다. 앞으로 볼 Thread 클래스도 여기에 속하고, 나중에 더 배운다면 java.util.concurrent 패키지도 사용하겠죠? 하지만 여기선 Thread까지만 언급할 겁니다.(제가 여기까지만 배우고 던졌기 때문에…)는 아니고 concurrent 패키지는 멀티코어 프로그램을 지원하기 때문에 어렵더라고요… 구현할 것도 많아 보여서 스킵했습니다.
그럼 Thread 클래스를 쓰기 앞서 프로세스와 쓰레드에 대해 개념을 잡고 갑시다. 일단 컴퓨터 내에는 많은 쓰레드와 프로세스가 돌아가고 있습니다. 만약 여러분이 코어가 하나인, 그러니까 뇌가 하나 달린 CPU를 쓰더라도 이렇게 할 수 있습니다. 왜냐하면 이런 것들은 단위 시간을 프로세스만큼 쪼개서 이 프로세스 했다 저 프로세스 했다 하는 것, 즉 타임 슬라이싱을 사용해서 실행되고 있기 때문입니다. 여러분이 인지를 못하는 것은 CPU가 하나 하나 일을 하는 게 분신술처럼 빨라서 여러 개를 동시에 하는 것처럼 보이는 겁니다.
(이렇게 일하고 있는 거에요…)
아무튼 여기서 프로세스란 자기만의 메모리 영역을 가지고 있는 완전한 실행 환경입니다. 그리고 쓰레드는 프로세스 안에서 작동하는 작은 프로세스입니다. 프로세스는 쓰레드 하나는 반드시 있어야 하고, 이는 보통 우리가 잘 아는 main() 메소드의 내용이 됩니다. 쓰레드는 반드시 프로세스 안에서 프로세스의 자원을 서로 공유하면서 작동해야 합니다.
그럼 쓰레드를 구현해봅시다. 쓰레드 구현은 두 가지 방법으로 할 수 있습니다.
Runnable 인터페이스 구현
public class
HelloRunnable implements Runnable {
public void run() {
System.out.println("Hello from a thread!");
}
public static void main(String args[]) {
}
}
위와 같이 구현하면 Thread 객체를 하나 더 생성해서 위의 Runnable을 실행시켜줘야 된다는 단점이 있지만, 단일 상속밖에 안되는 Java에서 (매우 소중한) extends 칸을 쓰레드에게 넘겨주지 않아도 된다는 장점이 있습니다.
Thread 클래스 상속
public class
HelloThread extends Thread {
public void run() {
System.out.println("Hello from a thread!");
}
public static void main(String args[]) {
}
}
아까와는 달리 Thread를 직접 상속 받아서 사용하는데, 이러면 자기.start()하면 실행된다는 직관적인 소스 코드를 작성할 수 있지만, 인터페이스로도 구현되는 쓰레드가 extends 옆을 꿰차고 있는 건 프로그램의 확장성에서 보면 안 좋은 코드입니다.
어쨋든 쓰레드가 할 일, 쓰레드의 main()은 public void run()의 몸체입니다. 여기에 실행할 내용을 적고, 쓰레드 객체를 생성해서 쓰레드.start()를 하면 run()이 실행됩니다.
(주의: run에 내용을 적고 start메소드로 실행해야 쓰레드가 동시에 작동합니다. run메소드를 실행하면 호출한 쓰레드가 호출된 쓰레드가 끝날 때까지 멈춰있습니다.)
참고로 튜토리얼에서는 Runnable로 쓰레드를 구현하는 것을 권장합니다. 확장하기 쉽다는 장점이 있기 때문입니다.
(쓰다가 피곤해진 상황… 뭐 API에 찾아보면 정확히 나오겠지...)
쓰는 메소드 중 튜토리얼에 나오는 거 몇 개
sleep(int ms): 현재 쓰레드를 밀리세컨드 동안 재워버림. 하지만 인터럽트를 받으면
InterruptedException이 발생함
interrupt(): 해당 쓰레드에 인터럽트를 발생시킴. isInterrupted()나 interrupted()라는 메소드로 현재 쓰레드에 인터럽트가 들어왔는지 확인할 수도 있지만, 복잡한 프로그램의 경우
InterruptedException을 try-catch해서 확인한다.
join(): 해당 쓰레드가 종료될 때까지, 현재 쓰레드를 재워버림.
(주의: sleep과 join은 시간 지정이 가능하지만, 이 시간은 프로세스 타임이기 때문에 실제 시간과 맞지 않을 수도 있다.)
이제 그럼 동기화에 대해서 알아봅시다.(초코 우유 먹고 와서 머리가 돌아감)
여러 개의 쓰레드는 같은 프로세스 공간을 공유할 수 있다고 했습니다. 이 때, 둘 이상의 쓰레드가 같은 객체의 값을 건드리면 어떻게 될까요? 하나는 0에다가 1을 더하고, 다른 하나는 0에다가 1을 뺄 때, 과연 이 객체의 값은 0이 될까요? 동기화를 하지 않았다면, 1이 될 수도 있고 -1이 될 수도 있습니다. 위의 예는 쓰레드가 가지는 전형적인 문제인데 이와 같은 문제들에 대해 조금 더 자세히 살펴보도록 하죠. 그리고 해결 방안인 동기화에 대해 알아봅시다.
쓰레드를 다룰 때는 대표적인 두 개의 문제를 조심해야 합니다. 하나는 쓰레드 간섭이고 다른 하나는 메모리 일관성 오류입니다.
쓰레드 간섭: 위에서 예를 들었던 것이 쓰레드 간섭입니다. 쓰레드 간섭은 두 개 이상의 쓰레드가 같은 값에 대해 변경했을 때, 한 쪽에서 변경한 값이 다른 한 쪽이 덮어 씌워서 원하는 값이 나오지 않을 때를 말합니다.
위의 경우를 과정을 자세히 따라가면 먼저 A가 0을 받아서 1을 증가시키고, 다시 저장하는 과정과, B가 0을 받아서 1을 감소시키고, 다시 저장하는 과정이 있다고 합시다. 이를 타임 슬라이싱, 다시 말해 A가 일했다가 B가 일했다가를 반복한다면, 나올 수 있는 과정이 A가 0을 받고, B가 0을 받은 후, A가 1을 증가시키고, B가 1을 감소시켜서, A가 1을 저장했는데 B가 -1을 저장해서 원하던 0이 아닌 -1이 나오는 쓰레드 간섭이 발생합니다.
메모리 일관성 오류: 만약 여러분이 0인 값을 1로 바꾼 후 이 값을 받아내는 과정을 1로 바꾸는 쓰레드와 값을 받아내는 쓰레드로 확인하려고 한다면, 앞의 쓰레드가 먼저 일을 하고 뒤의 쓰레드가 일을 해야 정답이 나오겠죠? 하지만 타임 슬라이싱은 앞의 쓰레드가 먼저 할 수도 있고 뒤의 쓰레드가 먼저 할 수도 있는 예측 불가능 한 것이기 때문에 여러분은 메모리의 값을 0으로 볼 수도 있고, 1로 볼 수도 있습니다. 이러한 것을 메모리 일관성 오류라고 합니다.
사실 2번의 경우는 순서를 정해서 즉, Happen-Before Relationship을 정해서 해결할 수 있습니다. 앞에서 배웠던 join으로 기다리게 하든지, 뒤의 쓰레드를 앞의 쓰레드가 끝나면 start()를 하든지를 통해서도 가능하지만, 방법이 제한적입니다. 따라서 우리는 동기화를 사용할 줄 알아야 합니다.
동기화란 현재 쓰레드가 어떤 객체에 대해 작업을 하고 있을 때, 다른 쓰레드가 이 객체에 대해 일을 하지 못하게 하는 것을 말합니다. 아까처럼 1을 증가시키는 중에는 1을 감소시키는 행위를 막아버리는 것을 동기화라고 합니다. 방법은 synchronized라는 키워드를 사용해서 동기화를 할 수 있습니다.
이 키워드는 메소드 또는 메소드 내용 중 일부에 사용할 수 있습니다.
public class
SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
이 것은 메소드 자체에 사용하는 예로, 이렇게 하면, 이 클래스의 synchronized 메소드를 사용하는 순간, 다른 쓰레드가 이 객체에 대한 작업을 시도하면, 현재 쓰레드가 작업을 끝낼 때까지 기다리게 됩니다. 현재 쓰레드는 이 때, lock(=monitor)이란 synchronized 객체를 사용할 권리를 갖고 있습니다. 만약 현재 쓰레드가 작업이 끝나면, 이 lock을 반환하고, 기다리고 있던 다른 쓰레드가 이 lock을 가지고 작업을 하게 됩니다.
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}
다음은 synchronized를 메소드의 특정 부분만 사용하는 예입니다. 위와 같이 synchronized(lock을 줄 객체){}라고 적어야 합니다. 여기서는 this, 객체 자신이 addName()의 lastName = name;을 먼저 사용하는 쓰레드에게 lock을 넘겨주게 됩니다. 이렇게도 쓸 수 있는 이유는 메소드의 일부분만 객체에 접근하는데, 메소드 전체를 동기화해버리면, 메소드가 시간이 오래 걸리는 메소드인 경우, 다른 쓰레드는 “잠깐만 쓸 꺼면서 오래도 쥐고 있네” 거리면서 기다리다 홧병날겁니다. 그럼 프로그램이 느려지죠. 이 때문에 부분적인 동기화 구문이 사용되는 겁니다.
동기화는 쓰레드의 문제를 막아주지만 정작 자기 자신도 남용하면 문제가 생깁니다. 대표적으로 Deadlock, Livelock, Starvation이 있습니다.
Deadlock: 쓰레드끼리 서로 동기화 메소드가 끝나지 않아서 쓰레드들이 멈춘 경우를 말합니다. 예로 설명하죠. 두 사람이 있습니다. 서로 인사를 하려는데 너무 예절교육을 잘 받아서 반대쪽 사람이 고개를 들지 않으면 자신도 고개를 들지 않습니다. 이제 둘이 만나서 인사를 한다면, 둘이 고개는 숙였는데 이제 상대보고 고개를 들라고 해야하는데, 서로보고 상대방이 고개를 들어야지 내가 들겠다는 소리를 하면서 고개를 계속 숙이고 있는 겁니다. 이를 프로그램으로 설명하면, 둘이 고개를 숙였으니 서로 자신의 객체를 사용중입니다. 둘 다 자기 객체의 lock을 가지고 있죠? 이제 상대방의 고개를 들게 하려고 상대방의 메소드를 사용하면, lock을 이미 상대방이 가지고 있기 때문에, lock을 받을 때까지 기다립니다. 하지만 상대방 또한 자신의 고개 드는 메소드 때문에 lock을 받을 때까지 기다리므로 두 쓰레드가 멈추게 됩니다.
public class Deadlock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s"
+ " has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s"
+ " has bowed back to me!%n",
this.name, bower.getName());
}
}
public static void main(String[] args) {
final Friend alphonse =
new Friend("Alphonse");
final Friend gaston =
new Friend("Gaston");
new Thread(new Runnable() {
public void run() { alphonse.bow(gaston); }
}).start();
new Thread(new Runnable() {
public void run() { gaston.bow(alphonse); }
}).start();
}
}
Livelock: Deadlock과 상당히 유사하지만 다른 경우입니다. Livelock은 오히려 상대 쓰레드의 응답에 반응하는 쓰레드끼리 서로의 응답을 영원히 기다리고 있는 상태입니다. Deadlock과 다른 점은 Livelock은 말 그대로 응답을 계속 기다리기 때문에 죽은 쓰레드가 아니라는 점입니다. 위의 문제를 살짝 바꿔서 상대방보고 고개를 들라고 시키는 것이 아닌 상대방이 고개를 들기를 기다렸다가 고개를 드는 코드로 바꾼다면 livelock인 것입니다.
Starvation: 이름 그대로 한 쓰레드가 특정 객체를 오랫동안 붙잡고 있어서 다른 쓰레드들이 그 객체를 쓰지 못해 배고파 죽은 경우입니다. 실제론 너무 독점상태로 있어서 다른 쓰레드들이 일을 못하고 대기상태라 프로그램이 제대로 진행되고 있지 못하는 경우를 뜻합니다.
위의 대표적인 동기화의 세 문제 때문에 동기화를 쓸 때, 조심을 해야 합니다. 위의 세 문제들이 위험한 이유는 컴파일 에러가 아닌 런타임 에러기 때문에, 즉, 에러 메세지도 없고, 빨간 줄도 안 뜨는 문제라서 그렇습니다.
그럼 잠깐 동기화 문제 때문에 머리가 아팠으니 이를 피하는 예외를 몇 개 알고 마지막으로 lock을 임의로 건네주는 방법을 알고 쓰레드 이야기를 끝내도록 할게요.
동기화는 쓰레드들이 한 객체를 써서 생기는 문제는 막는 것인데, 이 문제는 사실 객체를 쓰는 과정이 보통 정도 길이라도 타임 슬라이싱이 가능하기 때문에, 그 사이로 다른 쓰레드의 일을 집어 넣을 수 있어서 생기는 문제입니다. 그리고, 결국 객체를 바꾸려고 하기 때문에 생기는 문제지요. 따라서, 이 둘이 안 되는 경우는 동기화를 쓰지 않아도 됩니다.
객체에 접근하는 과정이 너무 짧은 경우: int나 byte같은 작은 자료를 읽고 쓰는 행위는 컴퓨터에게도 정말 짧게 걸립니다. 이런 경우에는 쓰레드 간섭이 발생할 수 없기 때문에 굳이 동기화를 하지 않아도 됩니다. 단, 이 경우, 메모리 일관성 오류를 제거하기 위해선 volatile 변수를 사용해야 합니다. volatile이 선언된 변수는 무조건 값을 쓰는 행위를 읽는 행위 전에 하는 것을 원칙으로 하기 때문에, 자동으로 happen-before relationship이 정해져 있고, 그렇기 때문에 읽을 때, 변경된 값을 볼 수 있습니다.
객체 자체가 수정이 불가능한 경우: final 객체의 경우는 수정이 불가능하기 때문에 굳이 잘못된 값을 읽을 것이라는 걱정이 생길 일이 없습니다.
이제 마지막으로 객체의 lock을 임의로 기다리고 전달할 수 있는 메소드 두 개를 알아보고 마치겠습니다. Object 클래스의 wait()와 notifyAll()입니다.
wait(): 현재 lock을 가지고 있는 쓰레드가 실행 중 이 메소드를 호출하면, lock을 반환하고 자신이 lock 대기열로 갑니다.(반드시 lock을 소지한 쓰레드가 이 메소드를 호출해야 함)
notifyAll(): 자신의 lock을 반환하면서 기다리고 있던 쓰레드 중 대기순위 첫번째를 깨운다. 일어난 쓰레드는 자신이 wait()했던 시점부터 lock을 가지고 실행한다.
이 두 메소드를 쓰는 경우는 보통 Producer-Consumer 방법에서 사용합니다. 한 객체를 택배상자처럼 한 쓰레드가 객체에 값을 넣는 생산자, 다른 쓰레드는 객체에 값이 들어있으면 그 값을 뜯어서 사용하는 소비자처럼 행동하는 방법이 Producer-Consumer = 생산자-소비자 방법입니다. 이 때, 소비자는 생산자가 값을 넣을 때까지 무한루프를 돌리는 것은 프로그램 성능에 있어서 매우 좋지 않은 방법입니다. 이 대신, 소비자는 값을 꺼낼 때, 먼저 객체 안에 아무것도 없으면 wait()를 한 다음 생산자가 값을 넣고, notifyAll()을 할 때까지 기다리도록 하면, 기다리는 동안, 프로그램은 이 쓰레드에 대한 일을 하지 않아도 되고, 쓰레드끼리는 정상적인 Happen-Before Relationship을 만들 수 있습니다.
이제 쓰레드에 대한 내용이 끝났습니다. 사실 영어 읽기 싫어서 제 머리 속에 있던 예전 지식을 많이 끌어다 써서 정확한 설명인지는 모릅니다. 그러니 그냥 구글신을 믿으세요…
인테그레이션 테스트 우왕