🎼 멀티 쓰레드란?
프로세스 내부에서 실행 단위인 쓰레드를 여러개로 나눠 여러 개의 작업을 동시에 처리하는 것
synchronized
에 대해 공부하기 전에 간단하게 쓰레드에 대해 알아보겠습니다.
자바에서는 쓰레드를 Thread
클래스를 직접 인스턴스화해서 사용하거나, 상속해서 하위 클래스를 만들어 생성할 수도 있습니다.
Thread
클래스에 들어가보면 사진처럼 Runnable
객체를 받거나 다양한 인자를 받을 수 있는 여러개의 생성자가 오버로딩 되어있습니다.
Name
만 받는 생성자는 target
이 null
인 것에 주목해주세요.
모든 생성자는 this
를 통해 사진의 생성자를 호출합니다.
이제 쓰레드를 start()
해주면 쓰레드가 시작됩니다.
여기서 start0()
는 네이티브 메서드로, 쓰레드를 스케쥴러에 포함시키고 Runnable
로 대기하다 수행 차례가 되면 run()
으로 시작합니다.
앞서 생성자로 target
이 null
이라면
class Task implements Runnable{
private int number;
public Task(int number) {
this.number = number;
}
@Override
public void run() {
System.out.println(number + "번 쓰레드 입니다!!");
}
}
public class Main {
public static void main(String[] args) {
Thread thread = new Thread();
Thread threadWithRunnable = new Thread(new Task(1));
Thread threadWithName = new Thread("쓰레드 입니다.");
System.out.println(thread.getName()); // Thread-0
System.out.println(threadWithRunnable.getName()); // Thread-1
System.out.println(threadWithName.getName()); // 쓰레드 입니다.
thread.run();
threadWithRunnable.run(); // 1번 쓰레드 입니다!!
threadWithName.run();
}
}
---------------------
Thread-0
Thread-1
쓰레드 입니다.
1번 쓰레드 입니다!!
다음의 코드를 실행시켜봅니다.
thread
는 이름, Runnable
객체를 주지 않았기 때문에, getName()
에서 자동으로 할당된 이름인 'Thread-'접미사에 생성 번호인 0이 붙어서 출력됐고, run()
은 Runnable
이 null
이기 때문에 아무것도 실행되지 않았습니다.
threadWithRunnable
은 Runnable
을 상속한 Task
라는 클래스를 인자로 받기 때문에 Task
안에 있는 run
이 실행됐습니다.
위에서 공부한 쓰레드를 생각해보며 다음의 코드를 확인해봅시다.
public class Singleton {
private static Singleton singleton;
int number = 0;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
public void increase(String thread) {
int temp = number;
System.out.println(thread + ": before = " + temp);
temp = temp + 1;
this.number = temp;
System.out.println(thread + ": after = " + number);
}
public int getNumber() {
return number;
}
}
간단한 싱글톤 입니다. increase() 호출시 number를 1 증가시킵니다.
class Task implements Runnable{
private String number; // 몇번 쓰레드인지
private Singleton singleton;
public Task(String number) {
this.number = number;
this.singleton = Singleton.getInstance();
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
singleton.increase(number);
}
}
}
그리고 Task
는 Singleton
을 멤버로 갖고 있습니다. 당연히 싱글톤이니 모든 Task
는 같은 Singleton
을 다루겠죠?
실행시 increase()
를 1000번 호출합니다.
public class Main {
public static void main(String[] args) throws InterruptedException {
Singleton singleton = Singleton.getInstance();
Thread threadWithRunnable1 = new Thread(new Task("t1"));
Thread threadWithRunnable2 = new Thread(new Task("t2"));
Thread threadWithRunnable3 = new Thread(new Task("t3"));
threadWithRunnable1.start();
threadWithRunnable2.start();
threadWithRunnable3.start();
sleep(1000);
System.out.println(singleton.getNumber());
}
}
그 다음 메인에서 쓰레드를 3개 만들고 실행시킵니다. 예상은 각각의 쓰레드마다 increase
를 1000번 호출하므로, 모든 과정이 끝나면 3000이 되야합니다.
하지만 결과는 실행마다 달라지지만, 3000이 나오지 않습니다.
실제로 734라는 중복된 값이 확인됩니다. 무슨 이유일까요?
그 이유는 그림과 같습니다. 특정 쓰레드가 increase()
의 결과를 반영하기 전에 number
의 값을 다른 쓰레드에서 읽었기 때문입니다.
우리는 이처럼 공유자원의 접근 순서에 따라 문제가 발생하는 것을 Race Condition이라 합니다.
원자적 연산으로 해결할 수도 있지만, 다른 방법을 생각해봅시다.
단순하게 동시에 하나의 쓰레드만 함수를 사용할 수 있도록 하면 됩니다.
increase
에 synchronized
를 추가해봅시다.
public synchronized void increase(String thread) {
int temp = number;
System.out.println(thread + ": before = " + temp);
temp = temp + 1;
this.number = temp;
}
저희가 의도한 3000이 정상적으로 출력됩니다.
synchronized
는 여러 쓰레드의 동시접근을 막는 키워드 입니다.
메서드나 코드 블럭으로 사용될 수 있습니다.
위의 예제에서 Task
는 Singleton
, 즉 같은 객체를 공유하면서 사용했고, 같은 객체의 synchronized
메서드를 사용했습니다.
하지만 increase()
의 synchronized
를 제거하고 Task
에서 synchronized
를 붙이면 어떻게 될까요?
class Task implements Runnable {
private String number;
private Singleton singleton;
public Task(String number) {
this.number = number;
this.singleton = Singleton.getInstance();
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
increase();
}
}
private synchronized void increase() {
singleton.increase(number);
}
같은 숫자가 반복됐고, 3000이 아닌 1000에서 끝났습니다.
이것으로 저희는 다른 객체에서 사용하는 synchronized
는 동기화를 보장해주지 않는다는 것을 알 수 있습니다.
syncronized
는 lock을 걸어서 메서드나 코드 블럭을 동시에 접근하는 것을 막습니다.
간단한 예제를 살펴보겠습니다.
public class Main {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread thread1 = new Thread(() -> {
task.run(1);
});
Thread thread2 = new Thread(() -> {
task.run(2);
});
thread1.start();
thread2.start();
}
}
class Task {
public synchronized void run(Integer number) {
System.out.println(number + " lock....");
System.out.println("do process in " + number);
System.out.println(number + " unlock....");
}
public void interrupt(Integer number) {
System.out.println(number + " interrupt!!");
}
}
간단하게 쓰레드 마다 lock, unlock을 표현해봤습니다.
당연히 같은 인스턴스를 사용하고, synchronized
메서드 이므로, 동시에 하나만 실행됩니다.
즉 lock을 공유합니다.
public class Main {
public static void main(String[] args) throws InterruptedException {
Task task1 = new Task(); //
Task task2 = new Task(); // 테스크를 다른 인스턴스로 설정
Thread thread1 = new Thread(() -> {
task1.run(1);
});
Thread thread2 = new Thread(() -> {
task2.run(2);
});
thread1.start();
thread2.start();
}
}
이번엔 다른 인스턴스로 설정해보겠습니다.
쓰레드마다 각각의 다른 Task
인스턴스를 주고 실행시켰습니다.
다른 인스턴스 이므로, lock을 공유하지 않고 각자 수행합니다. 여기까진 위에서 다뤘던 내용입니다.
전 'lock은 인스턴스 단위로 잠긴다'는 말 때문에 syncronized
가 사용된 인스턴스의 모든 메서드는 lock이 잠긴다는 오해를 했었는데요,
syncronized
와 아닌 메서드를 섞어서 사용하는 예제를 통해 설명드리겠습니다.
public class Main {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread thread1 = new Thread(() -> {
task.run(1);
});
Thread thread2 = new Thread(() -> {
task.interrupt(2);
});
thread1.start();
thread2.start();
}
}
class Task {
public synchronized void run(Integer number) {
System.out.println(number + " lock....");
System.out.println("do process in " + number);
System.out.println(number + " unlock....");
}
public void interrupt(Integer number) {
System.out.println(number + " interrupt!!");
System.out.println("print.... in " + number);
System.out.println(number + " interrupt end");
}
}
쓰레드 1은 정상적인 run을 하고, 쓰레드 2는 그걸 가로채서 inturrupt 한다는 의미로 작성해봤습니다.
run
은 synchronized
, interrupt
은 일반 메서드인데, run()
을 실행하면 인스턴스에 lock이 걸려서 다른 쓰레드에서 interrupt
에도 접근할 수 없는줄 알았습니다.
하지만 제 생각과는 달리 인스턴스에 lock이 걸린다는 것은 인스턴스에 있는 syncronized
전체가 lock을 공유한다는 의미였습니다. 다음 예제를 통해 확인해보겠습니다.
public class Main {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread thread1 = new Thread(() -> {
task.run(1);
task.interrupt(1);
});
Thread thread2 = new Thread(() -> {
task.run(2);
task.interrupt(2);
});
Thread thread3 = new Thread(() -> {
task.run(3);
task.interrupt(3);
});
Thread thread4 = new Thread(() -> {
task.run(4);
task.interrupt(4);
});
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
class Task {
public synchronized void run(Integer number) {
System.out.println(number + " lock....");
System.out.println("do process in " + number);
System.out.println(number + " unlock....");
}
public synchronized void interrupt(Integer number) {
System.out.println(number + " interrupt!!");
System.out.println("print.... in " + number);
System.out.println(number + " interrupt end");
}
}
인스턴스 단위로 lock이 걸린다는 의미는 사진과 같습니다.
run()
이 실행되며 lock이 잠김run()
또는 interrupt()
(run을 수행했다면 interrupt) 가 실행되며 lock이 잠김run()
또는 *interrupt()
... 끝날 때 까지즉, 인스턴스 안에 synchronized
가 붙은 메서드끼리 lock을 공유하며 하나의 작업이 끝나면 또 다른 작업이 lock을 잠그며 들어가는 방식입니다.
그래서 사진에서도 무조건 lock과 unlock, interrupt와 interrupt end가 올바른 쌍을 이루며 나타납니다.
synchronized는 순서를 보장하지 않기 때문에 1->2->3->4로 실행했어도 무작위로 lock을 가져갑니다.
synchronized
를 코드 블록으로도 만들 수 있습니다.
class Task {
public Task() {
}
public void run(Integer number) {
System.out.println("method call : " + number);
synchronized (this) { // 블록
System.out.println(number + " lock....");
System.out.println("do process in " + number);
System.out.println(number + " unlock....");
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread thread1 = new Thread(() -> {
task.run(1);
});
Thread thread2 = new Thread(() -> {
task.run(2);
});
Thread thread3 = new Thread(() -> {
task.run(3);
});
Thread thread4 = new Thread(() -> {
task.run(4);
});
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
이 때, 괄호 안에 무엇(객체의 참조)을 기준으로 락을 공유할지 설정할 수 있습니다.
당연히 하나의 Task
인스턴스를 사용하니까 문제가 없습니다.
만약 여러 개의 Task
를 사용하고, 클래스 기준으로 사용하고 싶다면?
class Task {
public Task() {
}
public void run(Integer number) {
System.out.println("method call : " + number);
synchronized (Task.class) {
System.out.println(number + " lock....");
System.out.println("do process in " + number);
System.out.println(number + " unlock....");
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Task task1 = new Task();
Task task2 = new Task();
Task task3 = new Task();
Task task4 = new Task();
Thread thread1 = new Thread(() -> {
task1.run(1);
});
Thread thread2 = new Thread(() -> {
task2.run(2);
});
Thread thread3 = new Thread(() -> {
task3.run(3);
});
Thread thread4 = new Thread(() -> {
task4.run(4);
});
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
쓰레드마다 다른 Task
를 할당했습니다.
이번엔 synchronized
블록에 Task.class
로 모든 Task
클래스에 대해 동기화를 진행했습니다.
모두 다른 Task
라도 동기화가 잘 진행되고 있습니다.
동기화는 공부하면 할수록 어려운 것 같습니다.
감사합니다 👋
좋은 글 감사합니다.