멀티스레드 (Multi-Thread)
개념
실행되지 않은 프로그램을 실행하게 되면 주기억장치에 해당 프로그램이 로딩되고 동작할 수 있는 상태인 '프로세스' 로 바뀝니다.
이러한 동적인 상태의 프로그램을 프로세스라고 하고 프로세스 내의 일련의 작업 단위 하나를 '스레드'라고 표현합니다.
어떤 프로세스든 기본적으로 하나의 메인 스레드가 존재 합니다. 메인 스레드를 중심으로 코드에 따라서 큰 흐름이 진행되는데 프로그램의
성질에 따라서 이러한 스레드가 여러 개 필요할 수 있습니다. 예를 들면 게임이나 웹 서비스 환경에서는 들어오는 요청 수가 동시적이면서
많을 수 있기 때문에 스레드 하나로 전부 처리한다면 간단한 요청에도 많은 시간이 걸릴껍니다. 그래서 Java는 이러한 환경에서 동시다발
적으로 들어오는 요청을 빠르게 처리하기 위해 여러 개의 스레드를 만들어 놓고 요청이 올 때 마다 스레드를 할당하여 요청을 처리합니다.
이러한 다중 스레드 처리 환경을 '멀티 스레드' 라고 합니다.
장점
응답성
싱글 스레드는 한 개의 스레드로 순차적으로 요청을 처리합니다. 그렇기 때문에 처리 성능 보다 요청이 더 많을 경우 성능이 저하되어 응답 속도가 느려지게 됩니다. 하지만 멀티 스레드의 경우에는 요러 개의 요청이 동시적으로 실행됩니다. (병렬성과 다름) CPU가 여러 스레드를 빠르게 Context Switching 하기 때문에 싱글 스레드보다 처리 속도가 빠를 수 있습니다.
자원의 효율성
여러 스레드는 프로세스 내 자원을 일부분 공유하기 때문에 효율적으로 자원을 사용할 수 있습니다. 멀티 프로세스의 경우 Context Swtiching을 할 때 자원을 공유하지 않기 때문에 캐시 메모리를 전부 비워야 하는 비용이 발생하지만 스레드는 자원을 공유하기 때문에 해당 비용이 발생하지 않습니다. 그렇기 때문에 성능에 플러스 요인이 될 수 있습니다.
단점
Context Swtiching 비용
프로세스 보다는 빠르지만 그렇다고 해서 아예 교환 비용이 발생하지 않는 것은 아닙니다. 그렇기 때문에 싱글 코어 환경에선 스레드 생성 비용, 잦은 스레드 Context Swtiching 으로 인한 비용 발생 등으로 성능 저하가 발생할 수 있습니다. 이러한 단점을 통해서 스레드는 무조건 많이 생성하는게 좋은 것이 아니라 요청 수에 맞는 적절한 스레드 설정이 중요하다는 것을 알 수 있습니다.
공유 자원에 대한 복잡성 증가
싱글 스레드의 경우 순차적으로 처리하기 때문에 공유 자원의 처리에 대해서 데이터 일관성을 유지합니다. 하지만 멀티 스레드 환경에서 공유 자원 사용 시 데이터 정합성에서 문제가 발생할 수 있습니다. 이런 문제를 적절히 처리하기 위해서 '동기화' 기법을 사용해야 합니다.
그렇게 되면 코드의 복잡성이 증가하게 되어 유지보수에 어려움을 겪을 수 있습니다. 그렇기 때문에 멀티 스레드 환경에서는 공유 자원을 최소화 하는 방향으로 개발하는 것이 좋을 것 같습니다.
공유자원과 임계영역
공유 자원
공유 자원은 말 그대로 프로세스 내에서 공유할 수 있는 자원입니다. Java 에서는 아래와 같이 static 키워드를 가지고 있는 필드의 경우 여러 스레드에서 공유됩니다.
public class Ticket {
private static int stock;
}
티켓 클래스의 재고의 경우 현재 티켓의 남은 수량이 공유 자원 입니다.
임계 영역
임계 영역은 공유 자원이 경쟁 상태에 들어가는 부분을 의미합니다. 해당 공유 자원을 사용하는 메서드의 경우 임계 영역이 될 수 있습니다.
public class Ticket {
private static int stock;
static {
stock = 1000;
}
public static int getStock() {
return stock;
}
public static void decrease() {
stock--;
}
}
다음과 같이 decrease() 메서드의 경우 공유 자원을 사용해 빼는 연산을 사용하고 있습니다.
여러 스레드에서 해당 메서드를 동시에 사용할 수 있는 문제점에 대해 살펴보겠습니다.
원자성 (Atomicity)
원자성은 더 이상 쪼갤 수 없는 최소 단위를 의미합니다. DB 원자성은 한 컬럼에서 쪼갤 수 없는 최소한의 데이터를 말합니다.
그렇다면 Java에서 원자성은 무엇을 의미할까요? Java 에서 원자성은 공유 자원에 대한 작업의 단위가 더 이상 쪼갤 수 없는 하나의 연산인 것 처럼 작동하는 것 입니다.
위에 있는 decrease 메서드의 -- 연산은 원자적일까요?
답은 그렇지 않다. 입니다.
왜 구문에는 하나의 연산처럼 보이는데 원자적이지 않다고 할까요? 사실 -- 연산이 컴퓨터 내부에서 어떻게 동작하는지 보면 3단계가 존재합니다.
1. 메모리에서 stock 변수값을 읽어옴.
2. 읽어온 stock 값을 변경함.
3. 변경한 값이 존재하는 레지스터 버퍼에서 메모리로 값을 덮어씀.
위의 1, 2번 과정 동안 만약 다른 스레드가 접근하여 메서드를 실행하면 해당 스레드 역시 아직 덮어쓰지 않은 값을 읽어오게 되고 결과적으로 첫번 째 스레드가 덮어썼던 값이 손실되고 두번 째 스레드가 값을 덮어씌우게 됩니다.
이처럼 원자성을 보장하지 못하면 데이터의 신뢰성, 정합성에 문제가 생깁니다. 중요한 데이터일 경우에는 큰 문제가 발생할 수도 있습니다.
우리는 동기화 기법을 사용해서 해당 공유 자원이 경쟁 상태에 빠지지 않게 원자성을 보장해야 합니다.
가시성 (Visibility)
가시성은 의미가 크게 와닿지 않을 수 있습니다. 제가 생각하고 정리한 Java 에서 가시성의 의미는 '올바르게 값을 바라보고 있는지' 입니다.
보통 한 개의 스레드가 한 개의 CPU(코어) 를 할당 받습니다. 그럼 항상 메인 메모리에 있는 값을 바라보고 있다고 생각하겠지만 CPU 에는 값을 저장해놓는 매우 빠른 레지스터 메모리가 존재합니다. 코어 마다 각자의 로컬 캐시를 사용하기 때문에 각자의 코어는 상대 코어의 캐시값을 알지 못합니다.
이해를 위해서 아래와 같은 그림을 보겠습니다.
이처럼 스레드 1이 카운터를 읽어오고 스레드 2가 동시에 카운터를 읽었습니다. 그리고나서 스레드 1은 7까지 카운트를 하고 스레드 2는 카운트를 하지 않았습니다. 스레드 1이 바뀐 값을 메모리에 저장하더라도 스레드 2는 캐시를 바라보고 있기 때문에 최신값을 바라보지 못합니다. 이처럼 가시성을 확보하지 않을 경우엔 메인 메모리 값을 보지 못하는 이상현상이 발생합니다.
동기화
개념
동기화는 한 스레드가 임계 영역에 접근하여 처리하는 동안 다른 스레드의 접근을 제한하여 데이터의 일관성을 보장하는 기법 입니다.
Java에서 대표적으로 쓰는 동기화 방식에 대해 살펴보겠습니다. 동기화를 하는 이유는 성능을 조금 희생하여 데이터의 원자성을 지킬 수 있고 원자성을 지키게 되면 데이터의 정합성, 신뢰성을 지킬 수 있습니다.
synchronized
synchronized 키워드를 사용하게 되면 임계 영역에 대해 키를 획득해 진입하게 됩니다. 해당 키워드는 메서드 전체나 특정 코드 블록에 대해서 스코프를 설정할 수 있습니다. 아까 위에서 본 decrease 메서드에 synchronized 키워드를 붙인다면 -- 연산의 3단계가 끝날 때 까지 다른 스레드가 해당 메서드에 진입할 수 없게됩니다. 이렇게 되면 -- 연산의 원자성을 보장하게 되어 데이터 일관성을 지킬 수 있게 됩니다.
private static int stock;
static {
stock = 1000;
}
public static int getStock() {
return stock;
}
public static synchronized void decrease() {
stock--;
}
}
volatile
volatile 키워드는 변수에 사용하는 키워드 입니다. 공유 자원에 volatile 키워드를 사용하게 되면 가시성을 보장할 수 있습니다.
아까 가시성을 보장하지 않은 경우 CPU cache를 바라보게 되어 메인 메모리의 값을 제대로 가져올 수 없다고 했는데 volatile을 사용하게 된다면 매번 READ나 WRITE를 할 때 CPU cache가 아닌 메인 메모리에서 값을 가져오게 됩니다. 이렇게 하면 가시성을 보장하게 됩니다.
하지만 주의할 것은 가시성을 보장한다고 해서 원자성까지 보장되는 것은 아닙니다. 데이터의 성질에 따라 가시성만 확보해야 하는 데이터가 있고 확실하게 둘 다 보장해야 하는 데이터가 있을텐데요 ! 요구사항에 따라 무분별하게 synchronized나 lock을 남발하는 것 보다 적절히 동기화를 하는 것이 성능과 데이터 일관성 사이에서 적절한 트레이드 오프를 찾는 것이라 생각합니다.
Atomic 자료형
원자성 + 가시성을 확보할 수 있는 자료형을 Java의 util 패키지에서 제공하고 있습니다. 가시성은 내부적으로 volatile 변수를 통해, 원자성은 CAS 메커니즘을 통해 확보하게 됩니다. 내부적으로 어떻게 값을 처리하는지 알아보겠습니다.
내부 코드를 살펴보면 volatile 키워드가 적용된 value를 통해 CPU 캐시가 아닌 JVM 에서 바로 자원값을 가져옵니다. 이를 통해 가시성을 확보하게 되고 CAS 알고리즘을 통해 원자성을 확보하게 됩니다. 어떻게 synchronized 키워드를 사용하지 않고 원자성을 확보할 수 있을까요? CAS에 대해서 알아보겠습니다.
CAS (Compare And Set)
Atomic 자료형 내부에 기대값과 연산된 값을 각각 저장하게 됩니다. 연산된 값을 메모리에 저장하기 전에 메모리에 있는 값과 기대값을 비교를 하여 같다면 연산된 값을 메모리에 그대로 저장합니다. 만약 두 값이 다를 경우 사용자가 어떻게 처리할지 정할 수가 있습니다. 이를 통해 원자성을 보장합니다.