Thread 활용하기

디우·2022년 10월 3일
0

자바에서 쓰레드 풀을 어떻게 생성하고, 쓰레드를 생성하면서 생길 수 있는 동시성 이슈에 대해서 그리고 이러한 문제를 해결하기 위한 동기화 툴에는 어떤 것이 있는지를 이론과 학습 테스트를 통해서 정리해보려고 한다.


Concurrency vs Parallelism

가장 먼저 많이 헷갈리는 개념인 동시성과 병렬성을 정리해 보려고 한다.
나의 경우에는 다음과 같은 질문으로 동시성과 병렬성을 구분하고 있다.

"어느 한 순간에(어느 한 시점에) 동작하는 작업이 몇개인가?"

위의 대답에 "하나" 라고 답한다면 이는 동시성이 될 수 있고, "여러 개"라고 하면 병렬성이 될 수 있다.
예를 들어 하나의 코어에서 멀티 쓰레드를 활용해서 동시에 여러개가 실행되는 것 처럼 보인다고 해도 어느 한 순간에(어느 한 시점에) 보게 되면 실제로는 하나의 작업을 수행하고 있는 것이다. 그리고 이를 우리는 동시성이라고 한다. 하지만 반대로 병렬의 경우에는 동시에 여러 태스크를 수행하고 있는 형식이다. 따라서 동시성은 서로 다른 두 태스크를 스위치(switch) 해줘야 하기 때문에 문맥교환(context switch) 비용이 들게 된다.

그리고 이러한 동시성을 자바에서는 쓰레드를 통해서 지원해준다.


프로세스 & 쓰레드

동시성 프로그래밍에서는 프로세스 혹은 쓰레드가 기본 실행 단위이다.
컴퓨터에서 디스크에 저장되어 있는 프로그램이 메모리 공간으로 올라오게 되어 CPU를 할당받아 실행하는 단계까 되면 우리는 이를 프로세스라고 말한다. 그리고 이러한 프로세스는 메모리에서 크게 4가지로 영역으로 구분된다.

가장 먼저 실행할 명령어들, 즉 코드들을 담고 있는 영역을 Code 라고 한다.
다음으로 전역 변수, 즉 static한 변수의 공간을 data 라고 한다. 이 메모리 영역은 프로그램이 메모리에 올라올 때, 한 번 할당된 이후에는 그 크기가 변하지 않는다.
heap 공간과 stack 공간은 프로그램의 실행에 따라 할당된 메모리 크기가 동적으로 변화하는 공간이다. 자바에서는 Object 타입의 데이터가 heap 영역에 할당된다. 그리고 이렇게 heap 영역에 생성된 Object 타입의 데이터에 대한 참조값이 stack에 쌓이며 이 외에도 private type 데이터들이 여기에 함께 할당된다.

프로세스는 서로 다른 프로세스끼리 서로 다른 4가지 메모리 영역을 가지게 된다. 하지만 쓰레드의 경우에는 Code 영역과 data, heap 영역은 공유하지만 쓰레드 각자만에 stack 공간을 가지게 되며, 한 프로세스 내에서 여러 동작의 흐름을 가질 수 있게 해준다.

(더 자세한 내용은 향후에 기회가 되면 프로세스와 쓰레드 라는 제목으로 포스팅해보도록 하겠다.😅)


동기화(Synchronization)

앞서 쓰레드에서 메모리 공간을 함께 사용한다고 언급하였다. 따라서 쓰레드 간에 데이터를 공유하는 것이 효율적이다 라고 이야기할 수도 있겠지만, 실제로는 이렇게 쓰레드간의 독립적인 환경이 보장되지 못해서 개발자를 곤란하게 하는 상황이 더 많다.

따라서 우리는 이렇게 쓰레드간에 데이터를 공유해서 발생하는 문제를 해결하고 쓰레드간에 동기화를 맞춰줄 필요가 있다.
여기서 쓰레드간에 데이터를 공유해서 발생하는 문제는 예를 들어 하나의 공유되는 변수에 대해서 두 개 이상의 쓰레드에서 증감 연산을 수행했을 때 등 예시가 많은데 쓰레드 간섭(Thread Interference)메모리 일관성 오류(Memory Consistency Errors) 와 같은 문제가 대표적이다.

쓰레드 간섭(Thread Interference)

쓰레드 간섭(Thread Interference) 는 앞서 언급한 예시에 대한 문제인데, 서로 다른 쓰레드에서 동일한 데이터에 접근하여 교차로 실행될 때(interleaving) 발생한다.

class Counter {
	private int c = 0;
    
    public void increment() {
    	c++;
    }
    ...
}

위와 같은 클래스가 존재한다고 보자. 여기서 c 라는 변수는 인스턴스 변수로 쓰레드간에 공유되는 영역에 들어가게 된다. 그리고 increment() 메소드의 후위 증가 연산의 경우 실제로 CPU 에 올라와 수행될 때에는 기존의 값을 레지스터에서 불러오고 CPU안에 존재하는 레지스터에서 증가시킨 후 다시 원래의 레지스터에 저장하는 총 3단계를 거치게 된다.(예전에 수업 때 배운 건데 3단계 거치는 것은 기억이 확실하나 레지스터나 CPU와 같은 내용은 정확한지 가물가물하네요...😅)

즉, 두 쓰레드에서 increment() 를 동시에 수행한다고 생각해보자. 우리는 각 쓰레드에서 1을 더해주므로 2가 될 것으로 기대한다. 하지만 실제로는 그렇지 않을 '수' 있다. 그렇지 않은 케이스를 보자.
먼저 두 쓰레드에서 각각 0이라는 값을 가져온다. 그리고 각각 증가 연산을 수행한다. 따라서 두 쓰레드 모두 1, 1 이라는 값을 가지게 된다. 그리고 나서 이를 할당한다고 생각해보자. c = 1; c = 1; 즉, c에 1이라는 값을 두 번 할당하는 것으로 끝날 것이다. 하지만 순차적으로 수행한다고 생각하면 가장 먼저 한 쓰레드가 1을 증가시켜 c는 1이 되었다. 그러고 나서 다른 쓰레드가 c의 값을 가져와 증가시키는데, 이 때 가져오는 c의 값은 1이므로 우리의 예상과 같이 2가 될 것이다. 이렇게 두 쓰레드 간에 간섭으로 인해 원하는 결과를 도출해내지 못하는 문제를 쓰레드 간섭(Thread Interference) 이라고 한다.

메모리 일관성 오류(Memory Consistency Errors)

다음으로는 메모리 일관성 오류(Memory Consistency Errors) 이다.
이는 서로 다른 쓰레드가 일관성 없이 같은 데이터를 바라볼 때 생기는 문제이다.
이를 회피하기 위해서는 happens-before relationship 를 이해해야한다.
(OS 수업 때 들었던 것인데 기억이 가물가물하네요...🥲)

public class Counter {
	private int c;
    
    public int getNext() {
    	return c++;
    }
    
    ...
}

먼저 쓰레드A 가 c 값을 getNext() 호출을 통해서 증가 시켰다고 해보자. 그러면 A가 아는 c의 값은 1이 된다. 그 상황에서 쓰레드 B는 c 가 0이라고 알고 있다. 여기서 A가 1로 변화시키기 전에 쓰레드B가 c를 출력할 일이 있다고 하면 0으로 보고 있으므로 0으로 출력될 것이다.
즉, 개발자가 happens-before relationship 을 고려해주지 않으면 쓰레드 A가 변경한 내용이 쓰레드 B에 적용된다는 보장을 해줄 수 없는 것이다.

자바에서 제공하는 synchronized 키워드를 쓰거나, Thread 클래스에 존재하는 join() 메소드를 실행시켜 두 쓰레드 간에 동기화를 해줄 수 있다.

Thread-sage class

그런데 쓰레드 간에 관계를 항상 고려하며 개발하기란 쉽지 않다. 그래서 우리는 애초에 쓰레드에 안정적이도록 프로그래밍을 할 수도 있고, 정리하면 다음과 같다.

  • 상태 변수(인스턴스 변수)를 쓰레드 간에 공유하지 않도록 한다.
  • 인스턴스 변수를 불변으로 만든다. (수정할 수 없도록 한다. 즉, 읽기만 가능하게 한다.)
  • 상태 변수에 접근할 때는 동기화를 고려한다.

즉, 인스턴스 변수를 통해서 객체에 상태를 줄 때에는 굉장히 신중해야한다는 것이고, 그러한 객체를 쓰레드간에 공유하는지를 유심히 고려해봐야한다고 볼 수 있다. 그리고 상태가 없는 객체는 항상 쓰레드에 안정적이라고 이야기할 수 있다.
예를 들어, 멀티 쓰레드 환경에서 동작하는 서블릿(Servlet)의 경우에는 인스턴스 변수 없이 메소드 내에서 지역변수만을 활용해서 동작하게 된다. (지역변수는 stack 영역에 저장되므로 각 쓰레드 별로 독립적이라서 앞서 언급한 문제가 발생하지 않는다.)


profile
꾸준함에서 의미를 찾자!

0개의 댓글