멀티스레드 프로그래밍 v1.헷갈리는 이론들을 정리해보자

sua_ahn·2024년 2월 27일
1

프로그래밍 입문

목록 보기
7/7
post-thumbnail

멀티스레드를 통한 병렬 프로그래밍

동시성 vs 병렬성

Concurrency vs Parallelism

일반적으로 동시성 ⊃ 병렬성

  • 동시 실행
    : CPU가 하나만 있는 경우, 응용 프로그램은 정확히 동시에 두 개 이상의 작업을 진행하지 못할 수 있다. 둘 이상의 작업을 동시에 진행하기 위해 CPU는 서로 다른 작업 간에 전환할 수 있다.

  • 병렬 실행
    : 컴퓨터에 둘 이상의 CPU 또는 CPU 코어가 있어서 둘 이상의 작업을 동시에 병렬적으로 진행할 수 있다.

동시와 병렬 뒤에 성질, 실행, 프로그래밍 중 어떤 것이 붙느냐에 따라 의미가 달라지고, 용어의 구별에 대해 완전한 합의가 이루어지지 않았으므로 대략적으로만 알고 있으면 될 듯 하다.

 

 


멀티스레드에서 주의해야 하는 것

👓 메모리 가시성 (Visibility)

여러 스레드가 각각의 CPU에서 실행되는 경우를 생각해보자!
각각의 CPU는 Cache를 가지고 있어서, 서로 다른 스레드는 Cache에서 업데이트되지 않은 이전 데이터(Stale Data)를 읽을 수 있다.

이때, 변수(객체)에 가시성을 부여하여, 반드시 Main Memory에 값을 쓰거나 읽도록 할 수 있다.

CPU Cache
Memory Wall 문제를 개선하기 위해 CPU 칩 안에 위치한 메모리

Memory Wall
CPU 발전속도보다 Memory 발전속도가 느리기 때문에
컴퓨터 시스템의 전체적인 성능이 Memory에 의해 결정되는 현상
→ 느린 Memory 접근으로 인해 지연과 에너지 소모 등의 문제가 발생

 

 

🤝 공유자원

자바 멀티스레드 환경에서는 스레드들끼리 static영역과 heap영역을 공유한다. 따라서 공유자원에 대한 동기화 문제를 신경써야 한다.

데이터 레이스 (Data Race)

여러 쓰레드가 공유자원에 동시에 접근하는 것을 데이터 레이스(Data Race, 데이터 경쟁) 라고 한다. 공유자원을 동시에 읽는 것 자체는 문제가 되지 않는다.
그러나, 서로 다른 두 스레드가 같은 공유 변수에 동시에 값을 할당하거나, 할당 중에 다른 스레드가 읽는 경우 잘못된 값을 읽고 쓸 수 있다. 이러한 상황에서는 원자적 연산을 사용하여 다른 연산이 중간에 끼어들지 못하게 할 수 있다.

경쟁 조건(race condition)
두 개 이상의 프로세스가 공통 자원을 병행적으로 읽거나 쓸 때, 공유 데이터에 대한 접근이 어떤 순서에 따라 이루어졌는지에 따라 그 실행 결과가 달라지는 상황

 

연산의 원자성 확보 (단일 연산)

공유변수를 사용할 때 문제가 되는 경우는 여러 스레드가 단일 연산이 아닌 연산을 동시에 수행하는 경우이다.

이때, 단일 연산은 하나의 일관된 동작이 한 Thread에서만 시작되고 종료되는 것을 의미하며, 따라서 다른 Thread에 의해 간섭 받을 수 없다.

'단일 연산'이란 개념을 처음 접하면 코드 한 줄은 단일 연산일 것이라고 쉽게 생각할 수 있다. 그러나 한 줄의 statement가 컴파일러에 의해 기계어로 변경되는 과정에서, 여러 개의 machine instruction이 만들어져 실행되면 다른 Thread에 의해 간섭 받을 수 있다.

그 대표적인 예로 i++ 연산을 들 수 있다. 연산 과정은 다음과 같다.

  1. i의 기존 값을 읽는다. (READ : Memory → Cache)
  2. i에 1을 더한다. (MODIFY : in Cache)
  3. i의 값을 변수에 할당한다. (WRITE : Cache → Memory)

3가지 동작 수행 중 다른 스레드에 의해 덮어쓰여질 수 있다.

 

연산 재배열 (Reordering)

메모리는 캐시에 비해 매우 느리기 때문에, 메인 메모리에 접근하는 횟수를 최소화하고 캐시를 적극 활용해야 한다. 캐시를 효과적으로 사용하기 위해 컴파일러와 CPU는 참조 지역성의 원리*에 의해 메모리 연산을 재배열한다.

그런데 이로 인해 서로 다른 스레드들의 작업 순서를 예측할 수 없게 되고, 타이밍에 따라 결과가 달라질 수 있다. 예를 들어, 공유 변수를 다른 스레드의 작업을 시작 또는 중단시키는 flag로 사용한다면, 이 변수에 대한 접근이 어떤 순서로 이루어졌느냐에 따라 매번 다른 결과를 만들어낼 수 있다.

참조 지역성의 원리
CPU가 사용할 데이터를 예측하는 방법

  • 시간 지역성
    : 최근 접근했던 메모리에 다시 접근하는 경향이 있음
  • 공간 지역성
    : 접근한 메모리 공간 근처를 접근하려는 경향이 있음

 

 


멀티스레드의 필수 조건

  1. 안정성 (데이터의 일관성)
    ↔ 프로그램이 의도대로 실행되지 않는 상황

  2. 활동성 (생존성)
    ↔ 다른 스레드의 영향으로 스레드가 멈추거나 대기하는 상황

 

🙆‍♀️ Thread-safe (스레드 안정성)

멀티스레드 프로그래밍에서 어떤 함수나 변수, 혹은 객체가 여러
스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에는 문제가 없음을 말한다. 이는 곧 데이터의 무결성을 의미한다.

그렇다면 스레드 안정성을 지키기 위해 가장 중요한 것은 무엇일까?

객체(변수)는 객체지향 프로그래밍의 가장 기초가 되므로, 이에 대한 접근을 관리하는 것이 중요하다. 특히, 공유되고 변경가능한 상태에 대한 접근을 관리하는 것이 가장 중요하다.

공유 됐다는 것은 여러 스레드가 특정 변수에 접근할 수 있다는 뜻이고, 변경할 수 있다(mutable)는 것은 해당 변수 값이 여러 스레드에 의해 변경될 수 있다는 뜻이다.

따라서, 스레드에 안전한 코드를 작성하기 위해서는 아래 세가지 중 하나는 반드시 지켜야 한다.

  1. 상태 변수를 스레드 간에 공유하지 않음
    ex. 로컬 변수를 메서드 내에서만 사용하고 반환하지 않음

  2. 상태 변수를 변경할 수 없도록 만듦
    ex. final 불변 객체

  3. 상태 변수에 접근할 땐 언제나 동기화를 사용
    ex. syncronized

프로그램이 정확하게 동작함 = 클래스 명세에 부합함

 

스레드의 재진입성 (Reentrant)

재진입성이란, 특정 스레드가 자기가 이미 획득한 락을 다시 확보할 수 있는 특성을 말한다. 위 설명만 들으면 '그래서 뭐 어떻다는 건데?'싶다. 일반적으로 재진입성과 스레드 안정성을 많이 혼동한다. 둘 다 자원을 처리하는 방법과 관련되나, 다른 개념이다.

재진입 가능한 함수는 하나의 스레드에서 여러 번 호출되어도 안전하게 동작할 수 있다. 이러한 특성은 여러 스레드에서 동시에 호출되어도 안전하게 동작할 수 있음을 의미한다. 따라서 재진입 가능한 함수는 스레드에 안전한 함수를 포함하는 개념이 된다.

재진입 가능한 함수의 일반적인 특징

  • 자기 동기화(Self-Synchronizing)
    : 함수 내부에서 사용되는 모든 자원에 대한 재진입이 가능하다. 즉, 함수 내부에서 락 또는 다른 동기화 메커니즘을 사용하여 자원을 보호하지 않아도 된다. (동기화 연산 필요 없음)

  • 상태 저장(State Preservation)
    : 함수가 호출되는 동안의 상태를 안전하게 저장하고 관리한다. 이는 재진입 가능한 함수가 중복 호출되었을 때 각 호출이 독립적으로 자신의 상태를 유지할 수 있도록 한다.

재진입 가능한 함수의 대표적인 예시

  • 재귀 함수
    : 함수가 자기 자신을 호출하는 재귀 호출을 사용하는 경우

  • ReentrantLock 클래스
    : Java에서 제공하는 재진입 가능한 락(lock) 구현체

JVM에서 재진입은 어떻게 동작하는가?

  1. 스레드가 락을 확보함

  2. JVM은 락을 소유한 스레드를 기록하고 확보 횟수(lock count)를 1로 지정함

  3. 같은 스레드가 락을 다시 얻으면 횟수를 증가시킴

  4. 이 스레드가 synchronized 블록 밖으로 나가 락이 회수되면 횟수가 감소함

  5. 횟수가 0이 되면 해당 락이 해제됨

 

🙆‍♂️ 활동성

재진입성은 재귀호출을 포함한 병렬실행을 보장하고 스레드 간에 동기화 연산을 필요로 하지 않는다. 반면, thread-safe하다는 것은 단지 여러 스레드에 의해 실행되더라도 문제만 없으면 된다는 완화된 조건이므로, 여러 스레드가 공유 자원에 동시에 접근하지 못하도록 lock으로 막아주기만 하면 된다.

결국, thread-safe한 코드는 멀티스레드 환경에서 reentrant 코드보다는 효율성이 떨어질 가능성이 높다. 다른 스레드에 의해 공유자원에 lock이 걸려 있다면, lock이 해제되기를 기다리며 수행을 멈추기 때문이다.
또한, 과도한 동기화로 인해 성능이나 활동성에 문제가 생길 수 있다.

교착 상태 (Deadlock)

: 둘 이상의 스레드가 서로가 가진 리소스를 기다리며 무한정 대기 상태에 빠지는 현상을 말한다.

데드락 방지 방법

  • 적절한 동기화 : 상호 배제를 사용한 동기화 피하기

  • 락 순서 관리 : 락을 일관된 순서로 획득하도록 함

  • 타임아웃 설정 : 락을 획득하지 못하고 일정 시간이 경과하면, 해당 락을 다시 요청하거나 다른 처리 방법을 선택하도록 함

  • 동기화 방식 변경 : ReentrantLock 등의 락클래스 사용

  • 데드락 탐지 및 해결 : 프로그램이 실행되는 동안 데드락 발생을 주기적으로 검사하고, 데드락이 발생할 경우 이를 해결하는 알고리즘을 구현

스레드 블로킹 (Thread Blocking)
: 스레드가 어떠한 이유로 일시적으로 작업을 중단하고 대기 상태에 들어가는 현상을 말한다.
예를 들어, 스레드가 파일을 읽거나 네트워크 연결을 기다리는 동안 블로킹 상태에 들어갈 수 있다.
한 스레드가 블로킹 상태에 들어가면 다른 스레드도 영향을 받아 일시적으로 멈출 수 있다

 

 


참고사이트 및 책

profile
해보자구

0개의 댓글