병렬 프로그램 작성은 상태가 바뀔 수 있는 내용을 어떻게 잘 공유해 사용하도록 관리하는 지에 대한 문제라고 했다.
여러 개 스레드에서 특정 객체를 동시에 사용하려 할 때 안전하게 동작하도록 객체를 공유하고 공개해야 한다.
1. 가시성
- 메모리 가시성
: 한 스레드에서 변경한 특정 메모리 값이 다른 스레드에서 제대로 읽어지는지
- 가시성 보장
: mutex , critical section을 사용하여 memory barrier를 만든다.
! mutex , semaphore 란?
뮤텍스란 MUTual EXclusion으로 상호배제 를 뜻한다.
Critical Section는 프로그램 상에서 동시에 실행될 경우 문제를 일으킬 수 있는 부분을 지칭한다.
그런 부분을 가진 스레드들의 동작시간이 서로 겹치지 않게, 각각 단독으로 실행되도록 하는 기술이다.
어느 스레드에서 cretical section을 실행하고 있으면 다른 스레드들은 접근할 수 없고 해당 스레드가 critical section을 벗어나기를 기다려야 한다.
세마포어 역시 데드락을 피하기 위한 기술 중 하나이다.
스레드가 critical section에 접근할 때 해당 스레드는 세마포어의 카운트를 감소시키고 수행이 종료된 후 세마포어의 카운트를 증가시킨다.
memory barrier란 중앙 처리 장치나 컴파일러에게 특정 연산의 순서를 강제하도록 하는 기능이다.
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
변수를 공유했지만 동기화하지 않은 코드이다.
멀티스레드에서 0을 print 할 수도 있고 terminate되지 않는 등 재배치 현상이 발생한다.
보통 cpu가 메인 메모리로의 접근이 필요할 때 ram -> cache -> cpu register로 읽은 후 명령을 수행한다. ( 물론 캐시에 값을 저장해놓고 사용 할 수도 있지만 생략...)
위의 사진은 자바 메모리 모델과 컴퓨터 하드웨어 메모리 아키텍쳐이다.
하드웨어 아키텍쳐는 스레드 스택과 힙을 구분하지 않는다.
때문에 변수들은 어느 시점에 JVM이 메인 메모리로 부터 데이터를 읽어서 cpu 캐시로 read하거나
캐시에서 메인 메모리를 데이터를 write 보장 할 수 없다.
내가 원하는 시점에 메인 메모리로 부터 데이터를 쓰는 것을 보장해주지 않기 때문에 위처럼 동기화를 하지 않으면 문제가 발생한다.
재배치란?
컴파일러 성능을 위해서 프로그램의 이미가 달라지지 않는 한에서 코드의 순서를 재조정하는 것이다.
동기화 기능을 지정하지 않으면 컴파일러나 프로세스 , JVM 등이 실행 순서를 임의로 바꿔 실행 할 수 있다.
동기화 되지 않은 상황에서 메모리상의 변수를 대상으로 작성해둔 코드가 " 반드시 이런 순서로 동작할 것이다" 라고 단정 할 수 없게 된다.
중요한 데이터를 다루는 상황에서 , 경우의 수가 1000만번 중 한번이라도 이로 인해 심각한 상황을 초래할 수도 있을 것이다.
Stale Data ( 스테일 데이터 )
- 동기화 하지 않으면 최신 값이 아닐 수 있다
- 더 큰 문제는 스테일 데이터가 나올 수도 있고 아닐 수도 있다는 것이다.
락과 가시성
락은 상호배제 뿐만 아니라 정상적인 메모리 가시성 확보를 위해서도 사용한다.
변경 가능하면서 여러 스레드가 공유해 사용하는 변수를 각 스레드에서 각자 최신의 정상적인 값으로 활용하려면 동일한 락을 사용해 모두 동기화 해야 한다.
volatile
동기화 방법 중 하나인 volatile 변수이다.
- 변수를 선언할 때 앞에 volatile을 붙이면 컴파일러는 해당 변수를 최적화에서 제외하여 항상 메모리에 접근하도록 만든다
- 약한 형태의 동기화
- 변수의 값이 바뀌었을 때, 다른 스레드에서 항상 최신의 값을 읽어 갈 수 있도록 보장
- 레지스터에 캐시 되지 않고 프로세스 외부 캐시에도 들어가지 않는다
- volatile로 선언되면, 컴파일러와 런타임이 이 변수가 다른 메모리 오퍼레이션과 재배치되면 안된다는 것을 알려준다
락은 가시성과 연산의 단일성을 모두 보장하지만 volatile 변수는 가시성만 보장한다.
사용시기
- 변수의 값을 변경하는 스레드가 하나만 존재할 때
- 다른 변수와 달리 불변 조건에 관련이 없을 때
- 변수에 접근할 때 락을 걸 필요가 없을 때
2. 스레드 한정
변경 가능한 객체를 공유할 때는 항상 동기화 해야 하지만, 특정 객체가 단일 스레드에서만 사용되는 게 확신되면 해당 객체는 따로 동기화 할 필요가 없다.
스레드를 한정하는 방법으로는 안정성을 확보 할 수 있다.
java와 DB를 연결해주는 JDBC 를 예로 들 수 있다.
JDBC Connection Pooling은 한쪽에서 DB연결을 사용하는 동안 다른 스레드가 사용하지 못하게 막는다.
공유하는 Connection 객체를 풀로 관리하면 특정 Connection을 하나 이상 스레드가 사용 못하도록 한정 할 수 있다.
먼저 volatile 변수를 이용해서 특정 스레드 한 곳에서만 쓰기 작업이 가능하도록 제한해서 임시방편으로 적용 할 수도 있지만..
되도록 스택 한정 기법이나 스레드 로컬 방식을 사용하도록 하자.
스택 한정
- 특정 객체를 로컬 변수를 통해서만 사용할 수 있는 특별한 한정 기법
- 로컬변수는 현재 실행 중인 스레드에 한정되어 있다고 볼 수 있다
- 스레드 내부의 스택에서만 존재하므로 다른 스레드가 볼 수 없다
- 단, 로컬 변수가 외부로 유출되어 참조를 다른 스레드에서 사용하지 않아야 한다
public class Animals {
Ark ark;
Species species;
Gender gender;
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals confined to method, don't let them escape!
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
}
위 코드에서 animals 라는 SortedSet 인스턴스는 지역변수로 선언되었다.
넘어오는 candidates 파라미터를 복사(addAll) 한 뒤에 추가적인 작업을 실행하는데 지역변수,
즉 stack 내부에서만 사용했기 때문에 동시성을 보장할 수 있다.
스레드는 각기 별도의 스택과 지역변수를 갖기 때문에 이런 방법으로도 동시성을 보장하도록 할 수 있다.
TreadLocal ( 스레드 로컬 )
스레드 로컬 클래스에는 get,set 메소드가 있고, 호출하는 스레드마다 다른 값을 사용할 수 있도록 관리해준다.
예를 들어 DB에 매번 Connection 인스턴스를 만들어서 접속하는게 부담스러워서 Connection 인스턴스를 전역 변수로 만들어 사용할 때
JDBC 연결은 스레드에 안전하지 않으므로 멀티 스레드에서 적절한 동기화 없이 전역변수로 사용하면 문제가 생긴다.
이를 TreadLocal을 사용하면 스레드는 저마다 다른 연결 객체를 가질 수 있다.
private static ThreadLocal<Connection> connectionHolder
= new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return connectionHolder.get();
}
TreadLocal의 get을 이용한 스레드 한정이다.
전역변수처럼 동작하기 때문에 전역 변수를 남발하는 결과를 초래할 수도 있다...
3. 불변성
- 객체를 동기화 하지 않고도 안전하게 사용할 수 있는 방법
- 거의 모든 문제가 여러 스레드가 예측할 수 없게 변경 가능한 값을 동시사용해서 발생한다
- 그렇기에 객체의 상태가 변하지 않으면 문제는 사라진다
- 불변 객체는 맨 처음 생성되는 시점을 제외하고 그 값이 전혀 바뀌지 않는다
- 불변 객체는 언제나 스레드에 안전
불변 객체는 생성 된 후 객체 상태를 변경 할 수 없고 내부의 모든 변수는 final로 설정되어 있어야 한다.
여담으로 java의 String 클래스의 내부를 들여다보면 final 클래스로 되어있다.
그래서 String 변수의 값을 변경할 때는 같은 주소값의 객체가 변경되는 것이 아니고 아예 새로운 객체가 생성된다.
@Immutable
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<String>();
public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
}
일반 객체를 사용하여 불변 객체를 구성한 모습
* 불변 객체를 공개할 때 volatile 키워드를 사용하면 스레드에 안전하다.
4. 안전공개
여러 스레드에 공유하도록 공개해야 할 상황은 있으며, 이럴 때 반드시 안전하게 공개해야 한다.
불변 객체가 아닌 객체는 모두 안전하게 공개해야 하며, 대부분 공개하는 스레드와 사용하는 스레드 양쪽 모두 동기화 방법을 적용해야 한다.
올바르게 생성 메소드가 실행되고 난 객체는
- 객체에 대한 참조를 전역 메소드에서 초기화
- 객체에 대한 참조를 volatile 변수 또는 AtomicRdference 클래스에 보관
- 객체에 대한 참조를 올바르게 생성된 클래스 내부 final 변수에 보관
- 락을 사용해 막혀 있는 변수에 객체에 대한 참조를 보관
가변객체를 사용할 때 공개하는 부분과 사용하는 부분 모두 동기화 코드를 작성해야 한다.
다음 포스팅은 객체를 공유하는 화장실 스케쥴링을 해보려고 한다.
'JAVA > JAVA 병렬프로그래밍' 카테고리의 다른 글
JAVA 병렬 프로그래밍 [5] - 화장실 스케쥴링 (1) | 2022.06.06 |
---|---|
JAVA 병렬 프로그래밍 [3] - 멀티 스레드로 1-100까지의 합 구하기 (0) | 2022.06.05 |
JAVA 병렬 프로그래밍 [2] - Thread Safety (0) | 2022.06.05 |
JAVA 병렬 프로그래밍 [1] (0) | 2022.04.16 |