초창기 컴퓨터는 단일 스레드로 동작하며 한 시점에 딱 한 가지 프로그램만 실행했었다. 그 때의 프로세서는 필요한 모든 자원에 직접 접근하고 자원을 비효율적으로 사용했었다. 하지만 운영체제가 등장하면서 여러 프로그램을 각자의 프로세스 내에서 실행하도록 발전했고, 각 프로세스마다 메모리, 파일 접근 권한 등을 OS가 관리해주기 시작하였다.
그렇다면 왜 굳이 여러 프로그램을 동시에 실행시킬 수 있는(멀티 프로세스를 지원하는) OS를 만든 것일까? 그 이유는 아래와 같다.
멀티 프로세스가 등장한 이유와 비슷한 이유(자원 활용, 공정성, 편의성)로 멀티 스레드가 등장하였다. 스레드의 등장으로 한 프로세스 안에서도 여러 개의 제어 흐름이 존재하게 되었다.
출처: https://eun-jeong.tistory.com/20
멀티 스레드로 인해 cpu 자원을 더 효율적으로 사용할 수 있게 되었지만, 그렇다고 멀티 스레드인 것이 항상 좋은 것이냐,라고 한다면 꼭 그렇다고 볼 수는 없을 것이다. 멀티 스레드로 동작할 때, 결과의 정확성을 보장할 수 없는 경우(thread-unsafety)가 발생하기 때문이다.
Thread Safety
일반적으로 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램 실행 시 항상 Correct한 결과가 나옴을 의미한다.
하지만 모든 함수가 thread-safe한 것이 아니고, 프로그램을 잘못 설계하면 thread-unsafe한 코드를 작성할 수 있다. 즉 여러 스레드가 공유되는 메모리 영역에 접근을 하다보면, 프로그래머가 예측하지 못한 결과들이 발생할 수 있는 것이다.
특히 서버 개발과 연결을 지어보면 톰캣은 외부 요청을 멀티 스레드로 처리하고, 자바 또한 멀티 스레드로 동작한다. 따라서 thread-safe한 코드를 짜는 것은 매우 중요하다.
따라서 아래에서는 thread-unsafe한 경우는 어떤 것들이 있는지 알아보고자 한다.
자바에서 thread-unsafe한 함수의 케이스는 크게 2가지로 정리할 수 있을 것 같다.
두 개 이상의 스레드에 의해 접근될 수 있는 공유 변수가 보호되지 않는 경우에 예상치 못한 결과가 발생할 수 있다.
다음 예제 코드를 살펴보자.
SharedObject.java
public class SharedObject {
public int count;
public SharedObject(int count) {
this.count = count;
}
public void increaseCount() {
++count;
}
}
IncreaseThread.java
public class IncreaseThread extends Thread {
SharedObject obj;
public IncreaseThread(SharedObject obj) {
this.obj = obj;
}
public void run() {
for(int i=0; i<1000; ++i) {
obj.increaseCount();
}
}
}
Main.java
import java.util.*;
class Main {
public static void main(String[] args) throws InterruptedException {
SharedObject obj = new SharedObject(0);
IncreaseThread t1 = new IncreaseThread(obj);
IncreaseThread t2 = new IncreaseThread(obj);
t1.start(); // t1 스레드 시작
t2.start(); // t2 스레드 시작
t1.join(); // t1이 종료될 때까지 기다리기
t2.join(); // t2가 종료될 때까지 기다리기
System.out.println(obj.count);
}
}
코드 설명
1. SharedObject: 말그대로 스레드1, 스레드2 간에 공유가 될 참조 자료형이다. 'count'라는 정수형 필드가 있고, 이 필드 값을 증가시키는 increaseCount() 메소드가 존재한다.
2. IncreaseThread: Thread 클래스를 상속받은 클래스로, 생성자로 SharedObject를 하나 전달받고, run() 메소드 안에서 그 객체의 메소드인 increaseCount()를 1000번 호출한다.
3. Main: main() 메소드가 있는 클래스이다. t1, t2라는 두 개의 스레드를 같은 SharedObject 객체를 생성자의 매개변수로 넘겨서 만들고 그 두 스레드를 시작(start() 호출)한 후 종료할 때까지 기다린 다음(join() 호출) 공유된 객체의 count 값을 출력한다.
increaseCount를 1000번씩 호출하는 스레드 두 개를 실행한 것이므로, count값은 2000이 나오기를 기대할 것이다.
하지만 실행해보면 2000 이하의 수가 나오는 것을 확인할 수 있을 것이다. (나오는 수는 실행할 때마다 달라진다.)
이런 일이 발생하는 이유는 사실 ++count;라는 연산은 하나의 연산으로 되어있는 것이 아니기 때문이다.
count를 1 증가시키기 위해서는
이렇게 3개의 연산 과정이 필요하기 때문이다. 이것이 왜 문제가 되냐면, 1, 2, 3 과정 중 다른 스레드로 cpu 제어권이 넘어가는 context-switch가 발생할 수 있다. 만약 다음과 같이 context-switch가 발생한다면 어떻게 될까?
현재 count가 0인 상태
1. t1이 count의 값(0)을 읽음
t2로 context-switching 발생
2. t2가 count의 값(0)을 읽음
3. t2가 count를 1 증가 시킴 (값 1)
4. t2가 그 count 값을 덮어쓰기함 (값 1)
t1으로 다시 context-switching
5. t1이 2.에서 읽어왔던 값을 1 증가 시킴(값 1)
6. t1이 그 count 값을 덮어쓰기 함(값 1)
increaseCount()는 t1, t2에서 총 2번 호출됐는데 결국 값은 1만 증가해버린 상황이 벌어졌다. 이런 상황 때문에 2000 이하의 수가 출력됐던 것이다.
이런 상황은 공유되는 변수를 지켜주지 않았기 때문에, 즉 어떤 스레드가 그 변수에 접근하고 있는 시점에는 다른 스레드는 접근할 수 없도록 장치를 해두지 않았기 때문에 발생한다.
check-then-act라는 형식의 코드에서 이런 문제가 자주 발생한다. 다음 예시 코드를 보자.
public class LazyInit {
private TempObj obj = null;
public TempObj getObj() {
if(obj == null) // 1번
this.obj = new TempObj(); // 2번
return obj; // 3번
}
}
obj가 이미 초기화 돼있다면 기존 객체를, 초기화 돼있지 않다면 새로운 객체를 만들어서 초기화 한 후 그것을 리턴하도록 하는 예제이다.
이 LazyInit 클래스의 객체가 2개 이상의 스레드에서 공유된다면 어떤 일이 벌어질까? 위 코드의 의도는 LazyInit이 2개 이상의 스레드에서 공유되더라도 obj는 하나의 객체만 존재하게 하여 어떤 스레드든 같은 객체를 참조하도록 하는 것일 것이다. 즉 일종의 싱글톤 디자인이라고 할 수 있다.
하지만 만약 다음와 같은 순서로 코드가 실행된다면 어떨까?
이런 순서로 실행된다면 결국 싱글톤을 구현하려고 했던 기존 설계가 깨지고 obj 객체가 2개 생겨서 2개의 스레드가 서로 다른 obj 객체를 참조하도록 되어버렸다. 이 또한 obj라는 공유되는 변수에 접근할 때 다른 스레드는 접근할 수 없도록 하는 보호장치를 해주지 않아 발생한 일이다.
static variable을 사용하면, 이 변수에 대한 thread-unsafety가 발생할 수 있는데, 이는 메모리에서 static 변수가 저장되는 영역 때문에 그렇다.
Java 프로그램이 실행되면 JVM은 runtime data area라고 하는 메모리 영역을 할당 받는다. Runtime data area는 PC register, stack, heap, method area, JNI 이렇게 5개의 영역으로 나눠지고 자바 바이트 코드를 실행하며 각각의 변수, 메소드 정보 등은 각자의 영역에 저장되어 실행되게 된다. 이 때 클래스 변수 즉 스태틱 변수들은 method area에 저장되는데, 이 method area는 모든 스레드가 접근할 수 있는 공유된 공간이다. 따라서 static 변수에 두 개 이상의 스레드가 동시에 접근할 가능성이 발생하게 된다.
위에서 봤던 예제를 약간 수정하였다.
IncreaseThread.java
public class IncreaseThread extends Thread {
private static int threadCount = 0;
public void run() {
for(int i=0; i<1000; ++i) {
++threadCount;
}
}
public int getThreadCount() {
return threadCount;
}
}
Main.java
import java.util.*;
class Main {
public static void main(String[] args) throws InterruptedException {
IncreaseThread t1 = new IncreaseThread();
IncreaseThread t2 = new IncreaseThread();
t1.start(); // t1 스레드 시작
t2.start(); // t2 스레드 시작
t1.join(); // t1이 종료될 때까지 기다리기
t2.join(); // t2가 종료될 때까지 기다리기
System.out.println(t1.getThreadCount());
System.out.println(t2.getThreadCount());
}
}
코드 설명
1. IncreaseThread: static 변수인 threadCount를 선언한 뒤 0으로 초기화 해주었다. 또, run() 메소드에서는 이 threadCount 변수를 1000번 증가시킨다.
2. Main: main() 메소드가 있는 클래스로, t1, t2 두 스레드를 실행시키고 끝날때까지 기다린 후 getThreadCount()로 static 변수인 threadCount를 출력한다.
결과는 아래와 같다.
이 코드 또한 실행시마다 결과 값이 다르며, 2000이 나올 때도 있지만 위처럼 2000 미만의 수가 나오기도 한다.
이것도 static 변수가 공유되는 변수인데 보호해주지 않아 생긴 문제이다.
예시를 하나 더 들어보려고 한다.
public class SaveValue {
public static int answer;
public void solve(boolean flag) {
if(flag) { // 1번
answer = 100; // 2번
} else { // 3번
answer = 0; // 4번
}
}
}
위 코드는 SaveValue라는 클래스를 정의한 코드인데, static int 변수인 answer와 solve() 메소드가 정의되어 있다. solve() method는 'flag'라는 매개변수의 값을 보고 true이면 answer에 100을 저장하고, false이면 0을 저장한다.
비슷한 상황을 계속 가정하게 되지만, 위 코드가 2개의 스레드에서 다음 순서로 실행된다면
이렇게 t1은 answer의 값이 100이어야 정상적으로 동작한 것인데, answer가 0인 상황에서 answer의 값을 읽게 되었다.
마지막으로, 자바 코드는 아니지만 static 변수의 사용으로 발생할 수 있는 thread-unsafety 상황을 예시로 들어보려한다.
static unsigned int next = 1;
int rand(void) {
next = next*1103515245 + 12345;
return (unsigned int)(next/65536) % 32768;
}
void srand(unsigned int seed) {
next = seed;
}
위 코드는 C언어로 작성된 코드로, 어떤 규칙을 가지고 유사 난수를 만들어 리턴하는 rand() 함수, rand() 함수에 영향을 주는 seed 값(next)을 변경하는 srand() 함수가 있다.
중요한 점은 여기서 난수 생성에 쓰이는 next 변수가 static으로, 여러 스레드가 공유하고 있다는 것이다.
만약 어떤 스레드가 srand(3)으로 seed를 3으로 설정한 후 rand()를 호출하였는데, 그 때 다른 스레드가 srand(5)를 호출하면 도중에 next의 값이 바뀌어 예상한대로 난수가 발생되지 않는 문제가 생긴다.
그렇다면 어떻게 이런 thread-unsafe한 상황을 해결할 수 있는 것일까? 그것에 대해서는 다음 포스트에서 이어서 작성해보려한다.
https://hbase.tistory.com/310
https://kspsd.tistory.com/m/43
https://github.com/scratchstudio/concurrency-in-practice
https://eun-jeong.tistory.com/20
https://ko.wikipedia.org/wiki/%EC%8A%A4%EB%A0%88%EB%93%9C_%EC%95%88%EC%A0%84
스레드 언세잎 유형 구분
https://velog.io/@junttang/SP-5.4-Thread-Safe-Functions
서버와 관련된
https://akku-dev.tistory.com/29
해결책이 잘 명시된
https://velog.io/@mangoo/java-thread-safety