본문 바로가기
Old Posts/Java

[Java] 자바 병렬 프로그래밍 - 멀티 스레드의 장단점

by A6K 2022. 3. 15.

복잡한 프로그램이 제대로 동작하도록 코드를 작성하는 일은 어렵다. 하지만 그 복잡한 프로그램이 빠르면서 제대로 동작하도록 작성하는 것은 더욱 어렵다. 즉, 어떤 작업들을 순차적으로 실행하는 프로그램보다 동시에 여러 작업들이 수행되도록 프로그램을 작성하는 것은 더욱 어렵다.

자바에서는 스레드를 이용해 프로그램의 여러 작업들을 동시에 실행하도록 해준다. 하나의 자바 프로그램에서 여러 스레드가 동작한다는 의미는 하나의 자바 프로그램이 여러 개의 프로세서를 활용해 최대한 성능을 끌어올린다는 것을 의미한다.

잘 알려져 있듯이 CPU의 성능을 향상시키기 위해 클럭 속도를 빠르게 올리는 방향에서 코어의 개수가 늘어나는 방향으로 전환되었다. 클럭 주파수를 끝없이 올리기엔 물리적인 한계가 있기 때문이다. CPU 코어의 클럭 속도가 눈부시게 빨라지는 시대에서는 소프트웨어 개발자에게 '공짜 점심(Free Lunch)'이 주어졌다. 하드웨어 속도가 빨라지기 때문에 아키텍처 측면에서의 고민이 없어도 다음 세대 하드웨어가 나오면 자연스레 소프트웨어의 처리 속도가 향상되었다.

하지만 Herb Sutter가 'A Fundamental Turn Toward Concurrency in Software'에서 언급했던 것처럼 공짜 점심은 끝났다. CPU의 성능이 클럭속도보다 코어 개수에 집중하기 때문에 소프트웨어 개발자들도 병렬처리의 세계로 들어갈 수 밖에 없다.

자바에서는 멀티 스레드를 이용한 병렬 프로그래밍을 통해 성능 개선을 도모해볼 수 있다.

출처 : pixabay.com

멀티 스레드의 이점

여러개의 스레드를 이용해 작성된 자바 프로그램은 몇 가지 이점을 갖는다.

우선 프로세서를 최대한 확보해서 프로그램의 전반적인 처리량을 늘릴 수 있다. 프로그램이 수행해야하는 작업을 여러개의 스레드 단위로 나누어서 실행하면, 각 작업들이 CPU 코어 하나씩을 점유해서 실행할 수 있어 전반적인 처리량이 늘어나게 된다.

마치 아침 식사를 준비하는 과정을 떠올려볼 수 있다. 커피와 계란 토스트를 준비하는 과정을 혼자한다면 우선 토스터기에 식빵을 넣어서 빵이 구워지기를 기다린다. 빵이 구워지면 계란 후라이를 한다. 계란 후라이가 다 되면 빵에 올려둔다. 그리고 커피 머신으로가서 커피를 만들고 만들어진 커피를 들고 자리에 앉아 식사를 시작한다. 이게 싱글 스레드에서 작업을 처리하는 가장 단순한 방법이다.

이제 이 작업을 세 명이 함께 처리한다고 생각해보자. 한명은 커피 머신에서 커피를 내린다. 다른 한명은 토스터기에서 식빵을 굽는다. 나머지 한명은 계란 후라이를 만든다. 이 작업이 동시에 진행되는 모습을 생각해보자. 이게 멀티 스레드 환경을 통한 처리량 향상이다.

두 번째로 작업의 모델링이 단순해진다. 세 명이 각각 하나씩 작업을 맡으면 작업이 단순해진다. 커피 내리는 사람은 식빵 굽는 것에 신경쓰지 않아도 된다. 계란 후라이를 하는 사람은 커피에 대해서 생각하지 않아도 된다.

만약 싱글 스레드에서 이런 것들을 빠르게 처리하려면 비동기적으로 처리해야하는데 커피를 그라인더에 갈다가 토스터기에 빵은 넣어두고, 다시 갈아 놓은 커피를 드리퍼나 커피 머신에 넣어두고, 가스레인지에 계란 후라이를 위해 기름을 두르다가 토스터가 다 구워지면 꺼내서 접시에 넣고, 다시 가스레인지로가서 계란 후라이를 하고... 말로만 들어도 굉장히 정신이 없다.

멀티 스레드를 잘 이용하면 복잡하면서 비동기적인 작업 흐름을 별도의 스레드에서 수행되는 더 단순하고 동기적인 작업으로 나눌 수 있다. 이런 작업들은 서로 필요한 경우에만 동기화 메커니즘을 통해 상호작용하게 된다. 이런 단순성은 결국 소프트웨어 버그의 발생확률을 줄여준다.

마지막으로 프로그램의 반응성을 향상시킬 수 있다. GUI 프로그램의 경우 뭔가 빡세게 작업하는 경우 사용자의 이벤트(마우스 클릭이나 키보드입력)에 바로바로 반응할 수 없는 경우가 있다. 만약 멀티 스레드라면 백그라운드 스레드에서 빡센 작업이나 입출력을 하면서 하나의 스레드를 사용자 입력을 처리하는데 사용할 수 있다.

멀티 스레드의 단점

멀티 스레드로 프로그램을 작성하는게 마냥 좋은 것만은 아니다. 멀티 스레드 프로그램을 작성할 때 발생할 수 있는 몇 가지 위험 요소들이 있다.

우선 스레드 안전성이라는 새로운 위험 요소가 생긴다. 예를 들어 다음 자바 코드를 생각해보자.

public class UnsafeNextValue {
    private int value;

    public int next() {
         return value++;
    }
}

이 코드는 얼핏보면 문제가 없어보인다. 0으로 초기화된 value 값을 next() 메서드가 호출 될 때마다 1씩 증가시키고 현재 값을 리턴한다. next() 메서드를 호출하면서 중복되지 않은 정수값을 매번 따오기 위해 작성한 코드이다.

싱글 스레드에서는 문제없이 잘 동작한다. 하지만 멀티 스레드 환경에서는 문제가 발생한다. 낮은 확률이긴 하지만 중복된 값이 각 스레드에서 리턴될 가능성이 있다.

value++는 코드에서는 하나의 명령처럼 보이지만 사실 3개의 연산으로 구성되어 있다. 메모리에 있는 value 값을 CPU 레지스터로 읽고, 레지스터에서 1을 더한 다음 그 값을 다시 메모리에 있는 value에 쓰는 작업이다.

이 코드가 두 개의 스레드에서 동작할 때, 각각 next() 메서드를 호출했지만 둘 다 같은 값을 리턴하는 시나리오가 발생한다.

메모리에 있는 value 값이 0인 상황에서 두 스레드가 동시에 시작했다고 생각해보자. 두 스레드는 메모리에 있는 값 0을 레지스터에 로드한다. 그리고 각각 독립적으로 +1 연산을 해서 둘 다 1이라는 값을 만들어 낸다. 이 후 두 스레드 모두 1이라는 값을 메모리에 있는 value 변수에 쓴다.

분명 next() 메서드는 두 스레드에서 각가 한번씩 호출되어 0과 1이 리턴되고, 2라는 값이 쓰여질 것으로 예상했지만 결과는 0이라는 값이 두 스레드에서 리턴되고, 1이라는 값이 쓰여졌다. 물론 대부분의 경우 0과 1이 리턴되고 2라는 값이 쓰여질 테지만 적은 확률이라도 중복되는 값이 나온다면 프로그램의 일관성에는 큰 타격이 있을 것이다. 예를 들어 계좌번호가 겹친다던지...

이 코드를 스레드 안전성을 갖춘 코드로 바꾸려면 다음과 같이 작성해야 한다.

public class SafeOperation {
    @GuardedBy("this")
    private int value;

    public synchronized int next() {
         return value++;
    }
}

자바에서 제공하는 synchronized 키워드를 메서드에 붙여 동시에 하나의 스레드면 next()를 실행하도록 수정하면 된다.

락을 이용해서 한번에 하나의 스레드면 연산을 실행할 수 있도록 강제하면 스레드 안전한 코드를 작성할 수 있다.

활동성 위험

스레드 안전성을 위해 락을 도입할 경우 필연적으로 활동성 문제가 생긴다. 안전성 문제는 잘못된 일이 생기지 않는다는 측면이라면 활동성 문제는 원하는 작업이 결국에는 일어난다는 측면이다.

데드락(Deadlock)

락을 도입했을 때 우선 '데드락(Deadlock)' 문제가 발생한다.

식사하는 철학자(Dining philosophers problem) (출처 : 나무위키)

데드락은 '식사하는 철학자(Dining philosophers problem)'로 설명할 수 있다. 식탁에서 5명의 철학자가 식사를 한다. 철학자들은 다음 절차를 통해 식사를 한다.

  1. 일정시간 생각한다
  2. 왼쪽 포크를 집는다. 왼쪽 포크가 사용중이라면 대기한다.
  3. 오른쪽 포크를 집는다. 오른쪽 포크가 사용중이라면 대기한다.
  4. 두 손에 있는 포크로 식사를 한다.
  5. 오른쪽 포크를 내려놓는다
  6. 왼쪽 포크를 내려놓는다.
  7. 1번으로 돌아간다.

철학자들이 식사를 하기 위해서 동시에 왼쪽 포크를 집는다면 모든 철학자들이 오른쪽 포크를 잡으려 대기할 것이기 때문에 아무도 식사할 수 없는 상황이 된다. 이런 상황을 교착상태 혹은 데드락(Deadlock) 상황이라고 한다.

라이브락(livelock)

데드락을 피하기 위해서 규칙을 하나 더 추가할 수 있다. 오른쪽 포크를 잡지 못하는 경우, 1분 정도 기다렸다가 양보하기 위해 잡고 있던 왼쪽 포크를 내려놓도록 하는 것이다. 이 경우 데드락은 발생하지 않는다. 왼쪽 포크를 잡은 상태에서 오른쪽 포크를 영원히 기다리는 시나리오는 발생하지 않기 때문이다.

하지만 1분 정도 기다렸다가 오른쪽 포크를 내려놓기 때문에 모든 철학자들이 왼쪽 포크를 집고, 1분동안 기다렸다가 내려놓는 작업을 영원히 반복할 가능성이 있다. 이런 상황을 ‘라이브 락(live lock)’이라고 한다.

기아 상태(starvation)

데드락과 라이브락 상황을 회피하기 위해서 작업의 우선순위를 줄 수도 있다. 즉, 나이가 많은 철학자가 식사를 하도록 배려하는 것이다. 나이 많은 철학자가 왼쪽과 오른쪽 포크를 집으려고 할 때, 나이 어린 철학자가 포크를 잡고 있다면 포크를 빼앗을 수 있도록 허용하는 것이다.

이 경우 나이가 가장 어린 철학자는 매번 포크를 빼앗겨 영원히 식사를 할 수 없게 될 수 있다. 은행 창구에서 내 번호를 기다리는데 앞으로 계속 새치기를 한다면 영원히 내 차례가 오지 않는 상황과 비슷한다. 이런 상황을 ‘기아상태(Starvation)’라고 한다.

잘 고려되지 않은 병렬 프로그램은 데드락이나 라이브락, 기아상태가 발생할 수 있다. 따라서 이런 활동성 문제가 발생하지 않도록 잘 고려해야한다.

출처 : pixabay.com

성능저하

여러 스레드에서 작업을 동시에 처리하는 것은 성능을 향상시키기 위함이다. 작업을 잘 쪼개서 동시에 실행한다면 성능은 향상될 것이다. 하지만 스레드의 개수가 일정 수준 이상 많아지면 오히려 성능이 떨어지는 현상이 발생하게 된다.

운영체제는 스레드 단위로 CPU 자원에 대한 스케줄링을 한다. 스레드는 스케줄러에 의해 CPU 자원을 획득하면 자신이 맡은 작업을 실행한다. 그러다가 스케줄러에 의해 CPU 자원을 놓아야 하는 상황이 오면, 자신이 실행하던 작업의 상황을 PCB(Process Control Block)에 저장해둬야 한다. 그래야 다음번 자기 차례에서 작업의 실행하던 부분부터 이어서 실행할 수 있기 때문이다. PCB에 작업 진행 상황을 저장하고, 다시 읽어들이는 과정을 컨텍스트 스위칭이라고 한다.

스레드가 너무 많으면 이런 컨텍스트 스위칭이 빈번하게 발생한다. 실제 작업을 수행하는 시간에 비해 컨텍스트 스위칭과 스케줄링에 소요되는 시간의 비율이 늘어나기 때문에 CPU 자원을 비효율적으로 쓰게 되고 전체적인 성능이 떨어지게 된다.

또 한, 스레드가 많아지면 데이터를 공유할 때 동기화 메커니즘을 사용해야한다. 이런 동기화 메커니즘은 컴파일러의 최적화를 방해하고, 메모리 캐시를 지우거나 무효화하기도 한다. 그 밖에 공유 메모리 버스에 동기화 관련 트래픽을 유발하기도 한다.

핵심은 스레드를 잘 알고 써야 장점은 가져가면서 발생할 수 있는 단점과 위험성을 최소화 할 수 있다는 점이다. 그래서 자바의 병렬처리에 대한 공부를 해야한다.

댓글