스레드는 CPU 이용의 기본 단위이며, 스레드 ID, 프로그램 카운터, 레지스터 집합, 스택으로 구성됨.
프로그램 카운터는 현재 실행 중인 명령 위치를 나타내는 값.
레지스터 집합은 CPU 실행 상태를 저장하는 공간.
스택은 함수 호출, 매개변수, 지역 데이터, 복귀 주소 등을 관리하는 구조.
같은 프로세스에 속한 스레드들은 각자 프로그램 카운터, 레지스터 집합, 스택을 가짐.
그러나 코드 영역, 데이터 영역, 열린 파일, 신호 같은 운영체제 자원은 같은 프로세스 안에서 공유함.
전통적인 프로세스는 하나의 제어 스레드를 가지는 단일 스레드 프로세스.
다중 스레드 프로세스는 하나의 프로세스 안에 여러 제어 스레드를 두어 동시에 하나 이상의 작업을 수행할 수 있는 구조.
단일 스레드 프로세스는 하나의 실행 흐름만 가지는 구조.
다중 스레드 프로세스는 여러 실행 흐름이 같은 자원을 공유하면서 병행적으로 동작할 수 있는 구조.
이 구조에서는 프로세스가 자원을 소유하는 단위이고, 스레드는 그 자원 안에서 실제 CPU를 사용하는 실행 단위.
동기
현대의 컴퓨터와 모바일 기기에서 동작하는 대부분의 소프트웨어 응용은 다중 스레드 구조를 사용함.
이미지 처리 프로그램은 여러 이미지의 축소판 생성 작업을 각각 별도 스레드에서 수행할 수 있음.
웹 브라우저는 한 스레드가 텍스트와 이미지를 표시하는 동안
다른 스레드가 네트워크에서 데이터를 가져오는 방식으로 동작 가능.
워드 프로세서는 사용자 입력 처리, 화면 표시, 맞춤법 및 문법 검사 작업을 여러 스레드로 나누어 수행할 수 있음.
이처럼 하나의 응용 프로그램 안에서도 서로 다른 작업이 동시에 진행될 수 있음.
웹 서버는 클라이언트 요청을 계속 받아들이는 스레드와
실제 요청을 처리하는 스레드를 분리하여 여러 요청을 병행 처리할 수 있음.
단일 스레드 웹 서버는 하나의 요청을 처리하는 동안 다른 요청을 기다리게 만들 수 있음.
반면 다중 스레드 웹 서버는 요청마다 별도 스레드를 사용하여 응답성을 높일 수 있음.
서버는 요청을 받으면 해당 요청을 처리할 스레드를 만들거나 스레드 풀에서 작업을 할당함.
그 뒤 서버는 다시 새로운 요청을 기다리는 구조.
운영체제 커널도 일반적으로 다중 스레드 구조를 사용함.
Linux 시스템은 부팅 중 여러 커널 스레드를 생성함.
각 커널 스레드는 장치 관리, 메모리 관리, 인터럽트 처리 같은 특정 작업을 담당하는 구조.
정렬, 트리, 그래프 알고리즘, 데이터 마이닝, 그래픽, 인공지능처럼
CPU 중심 작업이 많은 응용도 다중 스레드를 활용할 수 있음.
이러한 응용은 작업을 여러 실행 흐름으로 나누어 다중 코어 시스템의 처리 능력을 활용할 수 있는 구조.
장점
다중 스레드 프로그래밍의 주요 장점은 응답성, 자원 공유, 경제성, 규모 적응성.
응답성은 프로그램의 일부가 오래 걸리는 작업을 수행하더라도
다른 스레드가 계속 실행되어 사용자에게 반응할 수 있는 특성.
예를 들어 사용자 인터페이스 스레드는 계속 입력을 받을 수 있음.
동시에 별도 스레드는 백그라운드 계산, 파일 작업, 네트워크 작업을 수행할 수 있음.
자원 공유는 같은 프로세스 안의 스레드들이 메모리와 파일 같은 자원을 자연스럽게 공유할 수 있는 특성.
서로 다른 프로세스가 자원을 공유하려면 공유 메모리나 메시지 전달 같은 별도 기법이 필요함.
그러나 스레드는 같은 주소 공간을 공유하므로 협력이 쉬운 구조.
경제성은 프로세스를 새로 생성하는 것보다 스레드를 생성하고 관리하는 비용이 더 작다는 특성.
스레드는 이미 존재하는 프로세스의 자원을 공유하므로 프로세스 생성보다 일반적으로 더 가벼움.
문맥 교환 역시 프로세스 사이보다 같은 프로세스 안의 스레드 사이에서 더 경제적인 경우가 많음.
규모 적응성은 다중 처리기나 다중 코어 시스템에서 여러 스레드가 병렬로 실행될 수 있는 특성.
단일 스레드 프로세스는 처리기가 여러 개 있어도 하나의 처리기에서만 실행됨.
다중 스레드 프로세스는 여러 스레드를 여러 코어에서 동시에 실행할 수 있음.
다중 코어 프로그래밍
다중 코어 시스템은 하나의 칩 안에 여러 계산 코어를 포함하는 구조.
운영체제는 각 코어를 별도의 CPU처럼 볼 수 있음.
따라서 여러 스레드를 여러 코어에 나누어 실행할 수 있음.
다중 스레드 프로그래밍은 다중 코어 시스템의 처리 능력을 활용하기 위한 중요한 방식.
단일 코어 시스템에서도 여러 스레드는 존재할 수 있음.
그러나 한 순간에 실제로 실행되는 스레드는 하나뿐임.
이 경우 CPU가 스레드 사이를 빠르게 전환하기 때문에 여러 작업이 동시에 진행되는 것처럼 보이는 구조.
다중 코어 시스템에서는 여러 코어가 있으므로 여러 스레드가 실제로 동시에 실행될 수 있음.
병행성은 여러 작업이 같은 시간 범위 안에서 진행 중인 상태.
병렬성은 여러 작업이 실제로 같은 순간에 동시에 실행되는 상태.
단일 코어 시스템에서는 병행성은 가능하지만 병렬성은 불가능함.
다중 코어 시스템에서는 병행성과 병렬성이 모두 가능함.
병렬성은 병행성을 포함하지만, 병행성이 항상 병렬성을 의미하는 것은 아님.
프로그래밍 도전과제
다중 코어 프로그래밍은 단순히 스레드 수를 늘리는 문제가 아님.
프로그램을 병렬 실행에 적합하도록 분석하고, 작업과 데이터를 나누며, 실행 순서와 동기화 문제를 고려하는 과정.
주요 도전 과제는 태스크 인식, 균형, 데이터 분리, 데이터 종속성, 시험 및 디버깅.
태스크 인식은 응용 프로그램 안에서 병렬로 실행 가능한 작업을 찾아내는 과정.
전체 프로그램을 분석하여 서로 독립적으로 실행될 수 있는 작업 단위를 구분해야 함.
이상적으로는 각 태스크가 별도 코어에서 병렬로 실행될 수 있어야 함.
균형은 병렬로 실행되는 태스크들이 전체 작업에 비슷하게 기여하도록 작업량을 조절하는 문제.
어떤 태스크는 매우 많은 일을 하고 어떤 태스크는 적은 일을 한다면
일부 코어는 바쁘고 일부 코어는 대기하는 불균형이 발생함.
이 경우 여러 코어를 사용하더라도 전체 성능 향상은 제한될 수 있음.
데이터 분리는 각 태스크가 사용할 데이터를 적절히 나누는 문제.
작업을 여러 태스크로 나누었다면 각 태스크가 접근하고 조작할 데이터도 그에 맞게 분리되어야 함.
데이터가 적절히 분리되지 않으면 여러 스레드가 같은 데이터에 접근하면서 충돌이나 병목이 발생할 수 있음.
데이터 종속성은 한 태스크가 다른 태스크의 데이터나 결과에 의존하는 관계.
어떤 태스크가 다른 태스크의 결과를 필요로 한다면 두 태스크는 완전히 독립적으로 병렬 실행될 수 없음.
이 경우 실행 순서를 조율하거나 동기화 기법을 사용해야 함.
시험 및 디버깅은 병렬 프로그램에서 특히 어려운 문제.
다중 스레드 프로그램은 스레드 실행 순서가 매번 달라질 수 있음.
따라서 오류가 항상 같은 방식으로 재현되지 않을 수 있음.
이 때문에 병렬 프로그램의 시험과 디버깅은 단일 스레드 프로그램보다 훨씬 복잡한 작업.
Amdahl의 법칙
Amdahl의 법칙은 프로그램 안에 순차 실행 부분이 남아 있으면 코어 수를 늘려도 성능 향상에는 한계가 있다는 내용.
프로그램은 순차적으로 실행되어야 하는 부분과 병렬로 실행 가능한 부분으로 나눌 수 있음.
병렬 실행 가능한 부분은 코어 수 증가에 따라 빨라질 수 있음.
그러나 순차 실행 부분은 코어 수가 늘어나도 그대로 남음.
따라서 전체 성능 향상은 순차 실행 부분의 비율에 의해 제한되는 구조.
처리 코어 수가 N이고, 순차 실행 부분의 비율이 S라면 성능 향상은 순차 부분과 병렬 부분의 비율에 따라 제한됨.
코어 수가 무한히 증가하더라도 순차 실행 부분이 남아 있으면 최대 성능 향상은 일정 한계에 가까워짐.
Amdahl의 법칙은 다중 코어 프로그래밍에서 스레드 수를 늘리는 것보다
병렬화할 수 없는 부분을 줄이는 것이 중요하다는 점을 보여줌.
병렬 실행의 유형
병렬 실행의 유형은 데이터 병렬 실행과 태스크 병렬 실행.
데이터 병렬 실행은 동일한 데이터의 부분집합을 여러 계산 코어에 나누어 배치하고,
각 코어에서 같은 연산을 수행하는 방식.
예를 들어 큰 배열의 합을 구할 때 배열을 여러 부분으로 나눌 수 있음.
각 스레드는 자기 구간의 합을 계산하고, 이후 부분 결과를 합쳐 전체 결과를 만들 수 있음.
이 경우 모든 스레드는 같은 연산을 수행하지만 처리하는 데이터 범위가 다름.
태스크 병렬 실행은 데이터가 아니라 작업 자체를 여러 코어에 나누어 수행하는 방식.
예를 들어 한 스레드는 입력을 처리하고, 다른 스레드는 계산을 수행하며, 또 다른 스레드는 출력을 담당할 수 있음.
데이터 병렬 실행은 데이터를 기준으로 나누는 방식.
태스크 병렬 실행은 작업 종류를 기준으로 나누는 방식.
실제 응용 프로그램에서는 두 방식이 함께 사용될 수 있음.
다중 스레드 모델
다중 스레드 모델은 사용자 스레드와 커널 스레드가 어떤 관계로 연결되는지를 설명하는 구조.
사용자 스레드는 사용자 공간에서 스레드 라이브러리에 의해 관리되는 스레드.
커널 스레드는 운영체제 커널이 직접 관리하고 스케줄하는 스레드.
사용자 스레드는 커널 위에서 관리될 수 있음.
그러나 실제 CPU 실행을 위해서는 커널 스레드와 연결되어야 함.
대표적인 다중 스레드 모델은 다대일 모델, 일대일 모델, 다대다 모델.
다대일 모델
다대일 모델은 여러 사용자 스레드를 하나의 커널 스레드에 연결하는 방식.
스레드 관리는 사용자 공간의 라이브러리에서 수행됨.
커널은 하나의 커널 스레드만 인식함.
따라서 사용자 수준에서 여러 스레드가 존재해도 커널 입장에서는 하나의 실행 흐름처럼 보이는 구조.
이 방식은 사용자 공간에서 관리가 이루어지므로 비용이 작을 수 있음.
그러나 하나의 사용자 스레드가 봉쇄형 시스템 콜을 호출하면 전체 프로세스가 봉쇄될 수 있음.
또한 한 번에 하나의 스레드만 커널에 접근할 수 있음.
따라서 다중 코어 시스템에서 병렬 실행이 어려움.
초기 Solaris의 green thread와 초기 Java 스레드가 이 모델을 사용한 예.
일대일 모델
일대일 모델은 하나의 사용자 스레드를 하나의 커널 스레드에 연결하는 방식.
사용자 스레드 하나가 생성되면 이에 대응하는 커널 스레드도 생성됨.
하나의 스레드가 봉쇄형 시스템 콜을 호출하더라도 다른 스레드는 계속 실행될 수 있음.
여러 사용자 스레드가 여러 커널 스레드에 대응되므로 다중 코어 시스템에서 병렬 실행이 가능함.
따라서 일대일 모델은 다대일 모델보다 병렬성이 좋은 구조.
그러나 사용자 스레드를 만들 때마다 커널 스레드도 만들어야 함.
스레드 수가 많아질수록 생성과 관리 비용이 커질 수 있음.
Linux와 Windows는 일대일 모델을 사용하는 대표적인 운영체제.
다대다 모델
다대다 모델은 여러 사용자 스레드를 같거나 더 적은 수의 커널 스레드에 연결하는 방식.
개발자는 필요한 만큼 사용자 스레드를 만들 수 있음.
운영체제는 적절한 수의 커널 스레드로 실행을 지원함.
이 모델은 다대일 모델보다 병렬 실행에 유리함.
또한 일대일 모델보다 커널 스레드 수를 줄일 수 있는 구조.
사용자 스레드 수와 커널 스레드 수를 분리해서 조절할 수 있으므로 유연성이 높음.
스레드가 봉쇄형 시스템 콜을 호출해도 커널은 다른 스레드를 스케줄할 수 있음.
다만 구현이 복잡하다는 한계가 있음.
두 수준 모델은 다대다 모델의 변형.
두 수준 모델은 여러 사용자 스레드를 여러 커널 스레드에 매핑함.
동시에 특정 사용자 스레드를 하나의 커널 스레드에 고정적으로 연결할 수 있는 구조.
스레드 라이브러리
스레드 라이브러리는 개발자가 스레드를 생성하고 관리할 수 있도록 API를 제공하는 도구.
구현 방식은 사용자 수준과 커널 수준으로 나뉨.
사용자 수준 라이브러리는 코드와 자료구조가 사용자 공간에 존재하는 방식.
이 방식에서는 라이브러리 호출이 일반적인 함수 호출처럼 처리될 수 있음.
커널 수준 라이브러리는 스레드 관리 코드와 자료구조가 커널 공간에 존재하는 방식.
이 방식에서는 API 호출이 시스템 콜을 통해 처리되는 구조.
대표적인 라이브러리는 Pthreads, Windows 스레드, Java 스레드.
Pthreads와 Windows 스레드에서는
전역 변수로 선언된 데이터가 같은 프로세스의 모든 스레드 사이에서 공유될 수 있음.
Java는 전역 데이터 개념이 없으므로 공유 데이터에 대한 접근을 객체와 참조를 통해 명시적으로 구성해야 함.
스레드 생성 전략
스레드 생성 전략에는 비동기 스레딩과 동기 스레딩이 있음.
비동기 스레딩은 부모 스레드가 자식 스레드를 생성한 뒤 자신의 실행을 계속하는 방식.
이때 부모와 자식은 서로 독립적으로 병행 실행됨.
비동기 스레딩은 사용자 인터페이스 처리와 백그라운드 작업처럼 서로 독립적인 작업에 적합함.
동기 스레딩은 부모 스레드가 자식 스레드를 생성한 뒤 자식의 종료를 기다리는 방식.
부모가 자식들의 결과를 모아 최종 결과를 만들 수 있음.
따라서 동기 스레딩은 분할된 작업의 결과를 다시 합쳐야 하는 계산에 적합함.
Pthreads
Pthreads는 POSIX 표준에서 정의한 스레드 생성과 동기화를 위한 API 명세.
특정 구현 자체가 아니라 운영체제가 구현할 수 있는 표준 API.
Linux와 macOS를 포함한 여러 UNIX 계열 시스템에서 제공됨.
Pthreads 프로그램은 pthread.h 헤더 파일을 포함하여 작성함.
각 스레드는 pthread_t 자료형의 식별자를 통해 구분됨.
스레드 속성은 pthread_attr_t 자료형을 통해 지정할 수 있음.
기본 속성을 사용하려면 속성 객체를 기본값으로 초기화할 수 있음.
pthread_create 함수는 새 스레드를 생성하는 기능.
이 함수에는 스레드 식별자, 속성, 실행할 함수, 함수에 전달할 매개변수가 전달됨.
생성된 스레드는 지정된 함수에서 실행을 시작함.
pthread_join 함수는 부모 스레드가 자식 스레드의 종료를 기다리는 기능.
자식이 계산한 결과를 부모가 사용해야 한다면 pthread_join을 통해 동기화해야 함.
여러 스레드를 생성하는 경우에는 식별자를 배열로 관리할 수 있음.
반복문을 통해 pthread_create와 pthread_join을 사용할 수 있음.
이 방식은 여러 작업을 나누어 실행한 뒤 모든 결과를 기다리는 구조.
Windows 스레드
Windows 스레드는 Windows API를 통해 제공되는 커널 수준 스레드 기능.
Windows API를 사용하려면 windows.h 헤더 파일을 포함해야 함.
CreateThread 함수는 새 스레드를 생성하는 기능.
이 함수는 스레드가 실행할 함수, 매개변수, 스택 크기, 생성 플래그 등을 받아 새 스레드를 생성함.
부모 스레드는 반환된 핸들을 통해 자식 스레드를 관리할 수 있음.
WaitForSingleObject 함수는 하나의 스레드가 종료될 때까지 기다리는 기능.
WaitForMultipleObjects 함수는 여러 스레드의 종료를 기다리는 기능.
작업이 끝난 뒤에는 핸들을 닫아 자원을 정리해야 함.
Windows 방식은 Pthreads와 함수 이름과 API는 다름.
그러나 새 스레드를 만들고 종료를 기다린다는 기본 구조는 유사함.
Java 스레드
Java 스레드는 JVM 위에서 제공되는 스레드 API.
모든 Java 프로그램은 적어도 하나의 제어 스레드를 포함함.
main 메서드만 가진 프로그램도 JVM 내부에서는 하나의 스레드로 실행됨.
명시적으로 스레드를 생성하는 방법은 Thread 클래스를 확장하는 방식과 Runnable 인터페이스를 구현하는 방식.
Runnable 인터페이스는 run 메서드를 정의함.
run 메서드는 새 스레드에서 수행할 작업을 나타냄.
실제 새 실행 흐름을 시작하려면 run 메서드를 직접 호출하는 것이 아니라 start 메서드를 호출해야 함.
start는 JVM이 새로운 스레드를 만들고 그 안에서 run을 실행하도록 하는 기능.
run을 직접 호출하면 새 스레드가 만들어지지 않음.
이 경우 현재 스레드에서 일반 메서드처럼 실행됨.
join 메서드는 부모 스레드가 자식 스레드의 종료를 기다리도록 하는 기능.
이 기능은 Pthreads의 pthread_join과 Windows의 WaitForSingleObject에 대응되는 역할.
Lambda 표현식은 Runnable 작업을 더 간결하게 작성할 수 있게 하는 방식.
별도 구현 클래스를 만들지 않고도 스레드가 실행할 작업을 전달할 수 있음.
Java Executor 프레임워크
Java Executor 프레임워크는 Thread 객체를 직접 생성하는 방식보다 높은 수준에서
작업 실행과 스레드 관리를 지원하는 구조.
Executor 인터페이스는 Runnable 작업을 실행하는 execute 메서드를 제공함.
개발자는 Thread 객체를 직접 만들고 start를 호출하는 대신 실행할 작업을 Executor에 제출할 수 있음.
이 프레임워크는 작업 생성과 작업 실행을 분리하는 구조.
ExecutorService는 Executor를 확장하여 작업 제출, 스레드 풀 종료, 실행 상태 관리 기능을 제공함.
Runnable은 결과를 반환하지 않는 작업을 표현함.
Callable은 결과를 반환할 수 있는 작업을 표현함.
Future는 Callable 작업의 결과를 나중에 얻기 위한 객체.
ExecutorService의 submit 메서드는 Callable 작업을 제출하고 Future 객체를 반환할 수 있음.
Future의 get 메서드는 작업 결과가 준비될 때까지 기다렸다가 결과를 반환하는 구조.
Callable과 Future를 사용하면 별도 스레드에서 계산한 결과를 부모 스레드가 회수할 수 있음.
JVM과 호스트 운영체제
JVM은 일반적으로 호스트 운영체제 위에 구현됨.
JVM 명세는 Java 스레드가 하부 운영체제 스레드에 어떻게 매핑되어야 하는지를 구체적으로 정하지 않음.
따라서 Java 스레드의 실제 구현 방식은 JVM과 운영체제에 따라 달라질 수 있음.
Windows 위의 JVM은 Java 스레드를 Windows 커널 스레드에 매핑할 수 있음.
Linux와 macOS의 JVM은 Pthreads를 통해 Java 스레드를 구현할 수 있음.
암묵적 스레딩
암묵적 스레딩은 스레드 생성과 관리의 복잡성을 개발자가 직접 처리하지 않고,
컴파일러와 실행시간 라이브러리에 맡기는 방식.
다중 코어 시스템이 일반화되면서 수백 개 또는 수천 개의 스레드를 가진 응용이 가능해짐.
그러나 이를 개발자가 직접 모두 관리하는 것은 어려운 작업.
암묵적 스레딩에서는 개발자가 병렬로 실행 가능한 작업을 표현함.
실제 스레드 생성과 스케줄링은 라이브러리가 처리함.
따라서 개발자는 스레드 자체보다 태스크에 집중할 수 있음.
대표적인 암묵적 스레딩 기법은
스레드 풀, fork-join, OpenMP, Grand Central Dispatch, Intel Threading Building Blocks.
스레드 풀
스레드 풀은 미리 일정 수의 스레드를 만들어 두는 방식.
작업 요청이 들어오면 대기 중인 스레드가 해당 작업을 처리함.
요청마다 새 스레드를 생성하면 생성 비용과 종료 비용이 계속 발생함.
풀에 있는 기존 스레드를 재사용하면 작업 처리 비용을 줄일 수 있음.
스레드 풀은 동시에 실행되는 스레드 수를 제한하여 시스템 자원 사용을 조절하는 역할도 함.
작업을 마친 스레드는 종료되지 않고 다시 풀로 돌아가 다음 작업을 기다림.
풀의 크기는 CPU 수, 물리 메모리 용량, 동시 요청 수, 작업 성격에 따라 정해질 수 있음.
시스템 부하에 따라 크기를 동적으로 조절하는 방식도 가능함.
Windows API는 QueueUserWorkItem 같은 함수를 통해 작업을 스레드 풀에 제출할 수 있음.
스레드 풀은 작업을 즉시 실행하는 방식뿐 아니라 일정 시간 뒤 실행하거나 주기적으로 실행하는 방식에도 활용될 수 있음.
Android 스레드 풀
Android의 AIDL 기반 원격 서비스는 스레드 풀을 사용하여 여러 클라이언트 요청을 병행 처리할 수 있음.
AIDL은 Android Interface Definition Language의 약어.
AIDL은 클라이언트가 서버와 상호작용할 수 있는 원격 인터페이스를 정의하는 방식.
원격 서비스 요청은 풀 안의 개별 스레드에 의해 처리될 수 있음.
이 구조는 여러 클라이언트 요청을 동시에 처리하는 데 사용됨.
Java 스레드 풀
Java 스레드 풀은 java.util.concurrent 패키지를 통해 제공됨.
newSingleThreadExecutor는 하나의 스레드를 가진 풀을 생성함.
이 방식은 작업을 하나씩 순서대로 실행하는 구조.
newFixedThreadPool은 고정된 수의 스레드를 가진 풀을 생성함.
이 방식은 동시에 실행할 스레드 수를 제한할 수 있는 구조.
newCachedThreadPool은 필요에 따라 스레드를 생성하고 기존 스레드를 재사용하는 풀을 생성함.
이 방식은 작업 수가 유동적인 상황에서 사용할 수 있음.
이러한 메서드들은 ExecutorService 객체를 반환함.
execute 메서드는 작업을 제출하는 기능.
shutdown 메서드는 새로운 작업을 받지 않고 기존 작업을 완료한 뒤 풀을 종료하는 기능.
Java 스레드 풀은 Thread 객체를 직접 많이 생성하는 방식보다 작업 관리와 자원 제어에 유리함.
Fork Join
fork-join은 큰 작업을 작은 작업으로 나누고, 작은 작업의 결과를 다시 합쳐 전체 결과를 만드는 병렬 처리 방식.
fork는 작업을 분할하여 별도 실행 흐름으로 보내는 과정.
join은 분할된 작업의 결과를 기다리고 결합하는 과정.
이 방식은 재귀 분할 정복 알고리즘과 잘 맞음.
큰 문제를 작은 문제로 나누고, 작은 문제가 충분히 작아지면 직접 계산함.
그 뒤 결과를 다시 합쳐 전체 결과를 만드는 구조.
Java에서의 Fork Join
Java fork-join 프레임워크는 Java 1.7부터 제공된 병렬 처리 도구.
핵심 클래스는 ForkJoinPool, ForkJoinTask, RecursiveTask, RecursiveAction.
ForkJoinPool은 작업이 실행되는 스레드 풀.
ForkJoinTask는 fork-join 작업을 표현하는 기본 추상 클래스.
RecursiveTask는 결과를 반환하는 작업에 사용됨.
RecursiveAction은 결과를 반환하지 않는 작업에 사용됨.
배열 합산 같은 작업에서는 배열 구간이 충분히 작으면 직접 합을 계산함.
그렇지 않으면 구간을 나누어 하위 작업을 fork함.
하위 작업의 결과는 join을 통해 다시 결합됨.
work stealing은 작업을 끝낸 스레드가 다른 스레드의 작업 큐에서 남은 작업을 가져와 실행하는 방식.
이 방식은 작업자 스레드 사이의 부하 불균형을 줄이기 위한 기법.
OpenMP
OpenMP는 C, C++, FORTRAN에서 공유 메모리 병렬 프로그래밍을 지원하는 API와 컴파일러 디렉티브의 집합.
개발자는 코드에 지시문을 추가하여 병렬 실행 영역을 지정할 수 있음.
parallel 지시문은 병렬 영역을 지정하는 기능.
parallel for 지시문은 반복문을 여러 스레드에 나누어 실행하도록 지정하는 기능.
OpenMP를 사용하면 개발자가 직접 스레드를 생성하고 관리하지 않아도 병렬 실행을 표현할 수 있음.
OpenMP는 병렬 영역에서 사용할 스레드 수 지정 기능을 제공함.
또한 데이터 공유 여부 지정, 스레드별 전용 데이터 지정 같은 기능도 제공함.
Grand Central Dispatch
Grand Central Dispatch는 Apple의 macOS와 iOS에서 병렬 작업 실행을 지원하는 기술.
GCD는 런타임 라이브러리, API, 언어 확장의 조합으로 구성됨.
개발자가 병렬로 실행할 코드 섹션을 태스크로 식별하면, GCD가 스레딩에 관한 세부 사항을 대부분 관리하는 방식.
GCD는 작업을 디스패치 큐에 넣고, 큐에서 제거된 작업을 스레드 풀의 가용 스레드가 실행하도록 하는 구조.
디스패치 큐는 직렬 큐와 병행 큐로 나뉨.
직렬 큐는 작업을 FIFO 순서로 꺼내 한 번에 하나씩 실행함.
각 프로세스는 주 큐라고 부르는 고유한 직렬 큐를 가짐.
개발자는 필요에 따라 특정 프로세스에 로컬인 추가 직렬 큐를 만들 수 있음.
병행 큐도 작업을 FIFO 순서로 꺼냄.
그러나 병행 큐는 한 번에 여러 작업을 제거하여 병렬로 실행할 수 있음.
GCD에는 시스템 전체에서 공유되는 전역 병행 큐가 있음.
전역 병행 큐는 서비스 품질 클래스에 따라 구분됨.
QOS_CLASS_USER_INTERACTIVE는 사용자 대화형 클래스.
이 클래스는 반응형 사용자 인터페이스를 보장하기 위해 사용자와 상호 작용하는 태스크를 나타냄.
사용자 인터페이스 갱신과 이벤트 처리 같은 작업이 여기에 포함됨.
이 클래스에 속한 태스크는 아주 적은 양의 작업만 수행해야 함.
QOS_CLASS_USER_INITIATED는 사용자 시작 클래스.
이 클래스는 태스크가 반응형 사용자 인터페이스와 관련되어 있다는 점에서 사용자 대화형 클래스와 유사함.
그러나 사용자 시작 클래스의 태스크는 처리 시간이 더 오래 걸릴 수 있음.
파일을 열거나 URL을 여는 작업이 사용자 시작 태스크의 예.
사용자가 시스템과 계속 상호 작용하려면 이 클래스의 태스크는 완료되어야 함.
그러나 사용자 대화형 큐의 태스크만큼 빠르게 서비스될 필요는 없음.
QOS_CLASS_UTILITY는 유틸리티 클래스.
이 클래스는 완료하는 데 시간이 오래 걸리지만 즉각적인 결과를 요구하지 않는 태스크를 나타냄.
데이터 가져오기 같은 작업이 유틸리티 클래스에 포함될 수 있음.
QOS_CLASS_BACKGROUND는 백그라운드 클래스.
이 클래스는 사용자에게 보이지 않고 시간에 민감하지 않은 작업을 나타냄.
메일 시스템의 색인 생성, 백업, 유지 관리 작업처럼 즉시 완료될 필요가 없는 태스크가 백그라운드 클래스에 해당함.
GCD에 제출되는 태스크는 C, C++, Objective-C에서는 블록으로 표현될 수 있음.
블록은 독립적인 작업 단위를 표현하는 언어 확장.
블록은 함수와 유사하지만 코드 조각을 값처럼 전달할 수 있는 구조.
Swift에서는 블록 대신 closure를 사용하여 GCD에 제출할 작업을 표현할 수 있음.
GCD의 스레드 풀은 POSIX 스레드로 구성됨.
GCD는 풀을 적극적으로 관리함.
응용 프로그램의 요구와 시스템 용량에 따라 스레드 수를 늘리거나 줄일 수 있음.
Intel 스레드 빌딩 블록
Intel Threading Building Blocks는 C++ 병렬 프로그래밍을 위한 템플릿 라이브러리.
TBB는 특별한 컴파일러나 언어 지원 없이도 사용할 수 있음.
개발자가 직접 스레드를 관리하지 않고 병렬 작업을 표현할 수 있게 하는 방식.
개발자가 병렬로 실행 가능한 태스크를 지정하면, TBB 런타임 라이브러리가 해당 태스크를 하부 스레드에 매핑하고 스케줄링함.
TBB는 캐시 인식 기능을 제공함.
캐시에 저장된 데이터가 있으면 그 데이터를 참조하는 스레드와 연결함으로써 캐시 활용을 높일 수 있음.
TBB는 병렬 루프 구조를 제공함.
또한 원자적 연산, 상호 배제를 위한 동기화 도구, 병행 자료구조를 제공함.
병행 자료구조에는 해시 맵, 큐, 벡터 같은 구조가 포함될 수 있음.
이 자료구조들은 C++ 표준 템플릿 라이브러리 자료구조의 스레드 안전 버전처럼 사용할 수 있음.
parallel_for는 TBB의 대표적인 기능.
일반적인 for 반복문은 하나의 스레드가 반복을 순차적으로 수행함.
parallel_for는 반복문을 병렬로 실행할 수 있게 하는 구조.
parallel_for는 반복 범위를 여러 청크로 나눔.
각 청크는 여러 스레드에 의해 병렬로 처리될 수 있음.
TBB 런타임 라이브러리는 반복 공간과 작업 본문을 기반으로 태스크를 만듦.
이후 만들어진 태스크를 여러 스레드에 나누어 실행하는 방식.
스레드와 관련된 문제들
스레드 프로그램에서는 여러 실행 흐름이 같은 프로세스 자원을 공유함.
따라서 운영체제 차원의 여러 문제가 발생함.
주요 문제는 fork와 exec 시스템 콜, 신호 처리, 스레드 취소, 스레드 로컬 저장장치, 스케줄러 액티베이션.
Fork 및 Exec 시스템 콜
fork 시스템 콜은 새로운 프로세스를 생성하는 기능.
다중 스레드 프로그램에서 fork를 호출하면 새 프로세스에 하나의 스레드만 복제할지 모든 스레드를 복제할지가 문제.
일부 UNIX 시스템은 두 가지 방식의 fork를 모두 제공함.
하나는 fork를 호출한 스레드만 복제하는 방식.
다른 하나는 모든 스레드를 복제하는 방식.
fork 이후 바로 exec를 호출한다면 모든 스레드를 복제할 필요가 적음.
exec는 현재 프로세스 전체를 새로운 프로그램으로 대체하기 때문.
따라서 기존 스레드 구조도 함께 사라지는 구조.
반대로 fork 이후 exec를 호출하지 않는다면 새 프로세스가 모든 스레드를 복제해야 할 수 있음.
신호 처리
신호는 UNIX에서 프로세스에 특정 이벤트가 발생했음을 알리는 방법.
신호는 이벤트 발생, 전달, 처리의 과정을 거침.
신호 처리기는 디폴트 신호 처리기와 사용자 정의 신호 처리기로 나뉨.
디폴트 신호 처리기는 커널이 기본적으로 제공하는 처리 방식.
사용자 정의 신호 처리기는 프로그램이 직접 지정한 처리 방식.
동기식 신호는 실행 중인 명령 때문에 발생하는 신호.
불법 메모리 접근이나 0으로 나누기 같은 상황이 여기에 해당함.
이 신호는 발생 원인이 된 스레드에 전달되어야 함.
비동기식 신호는 실행 중인 프로세스 외부에서 발생하는 신호.
타이머 만료나 외부 종료 요청 같은 상황이 여기에 해당함.
다중 스레드 프로그램에서는 비동기식 신호를 어느 스레드에 전달할지가 문제가 됨.
가능한 방식은 신호가 적용될 스레드에 전달하는 방식.
또는 모든 스레드에 전달하는 방식.
또는 몇몇 스레드에만 선택적으로 전달하는 방식.
또는 특정 스레드가 모든 신호를 받도록 지정하는 방식.
Pthreads는 pthread_kill 함수를 통해 특정 스레드에 신호를 전달할 수 있음.
Windows는 UNIX 신호를 명시적으로 지원하지 않음.
그러나 APC를 사용하여 유사한 동작을 제공할 수 있음.
APC는 비동기식 프로시저 호출을 의미함.
APC는 특정 스레드에 이벤트 발생을 전달하는 방식.
스레드 취소
스레드 취소는 스레드가 정상적으로 끝나기 전에 종료시키는 작업.
취소 대상이 되는 스레드를 목적 스레드라고 부름.
스레드 취소가 필요한 예는 병렬 검색 작업에서 결과를 찾은 뒤 나머지 검색 스레드를 중단하는 경우.
또는 웹 브라우저에서 사용자가 페이지 로드를 중단하여 로딩 스레드를 취소하는 경우.
비동기식 취소는 대상 스레드를 즉시 종료시키는 방식.
지연 취소는 대상이 취소 요청을 확인하고 안전한 지점에서 스스로 종료되도록 하는 방식.
스레드가 공유 자료구조를 수정하는 중에 취소되면 데이터 일관성이 깨질 수 있음.
자원을 보유한 상태에서 취소되면 자원 누수나 교착 상태가 발생할 수 있음.
따라서 지연 취소가 공유 자원과 데이터 일관성을 지키기 위해 일반적으로 더 안전한 방식.
Pthreads에서는 pthread_cancel 함수로 취소 요청을 보낼 수 있음.
pthread_cancel은 대상 스레드를 즉시 종료시키는 것이 아니라 취소 요청을 표시하는 방식.
Pthreads의 취소 모드에는 취소 비활성화, 지연 취소, 비동기식 취소가 있음.
기본 취소 유형은 지연 취소.
취소점은 요청을 처리할 수 있는 지점.
pthread_testcancel 함수를 사용하면 명시적으로 취소점을 만들 수 있음.
정리 핸들러는 스레드가 취소될 때 보유한 자원을 해제하기 위해 사용될 수 있음.
Java에서는 interrupt 메서드로 중단 요청을 전달함.
대상 스레드는 isInterrupted로 상태를 확인할 수 있음.
Java의 방식은 지연 취소와 유사하게 스레드가 스스로 종료 여부를 판단하는 구조.
스레드-로컬 저장장치
스레드 로컬 저장장치는 같은 프로세스의 스레드들이 자원을 공유하더라도
각 스레드가 자기만의 데이터를 가질 수 있게 하는 기능.
TLS 데이터는 스레드마다 별도의 사본을 가지는 구조.
트랜잭션 처리 시스템에서 각 스레드가 고유한 트랜잭션 식별자를 가져야 하는 경우에 사용할 수 있음.
TLS는 지역 변수처럼 보일 수 있음.
그러나 지역 변수는 함수 호출 범위 안에서만 유효함.
TLS는 정적 데이터처럼 보일 수 있음.
그러나 정적 데이터는 프로세스 전체에서 하나의 사본을 공유함.
반면 TLS는 스레드 전체에서 접근 가능하면서도 스레드마다 고유한 사본을 가진다는 점이 특징.
Java의 ThreadLocal은 스레드별 데이터를 관리하기 위한 기능.
Pthreads의 pthread_key_t도 스레드 로컬 저장장치와 관련된 자료형.
C#과 gcc 컴파일러도 스레드 로컬 데이터를 위한 기능을 제공할 수 있음.
스케줄러 액티베이션
스케줄러 액티베이션은 사용자 스레드 라이브러리와 커널 사이에서 스케줄링 정보를 주고받기 위한 방식.
이 개념은 다대다 모델과 두 수준 모델에서 중요함.
두 모델에서는 사용자 스레드 수와 커널 스레드 수가 서로 다를 수 있음.
따라서 사용자 수준 스레드 라이브러리와 커널 사이의 조율이 필요함.
LWP는 사용자 스레드와 커널 스레드 사이의 중간 구조로 사용될 수 있음.
LWP는 경량 프로세스를 의미함.
사용자 스레드 라이브러리에서 보면 LWP는 사용자 스레드가 실행될 수 있는 가상 처리기처럼 보이는 구조.
각 LWP는 하나의 커널 스레드에 연결됨.
커널은 실제 물리 처리기 위에 커널 스레드를 스케줄함.
커널 스레드가 봉쇄되면 연결된 LWP도 봉쇄됨.
CPU 중심 응용은 처리 코어 수만큼의 LWP로 충분할 수 있음.
입출력 중심 응용은 동시에 여러 봉쇄형 시스템 콜이 발생할 수 있으므로 더 많은 LWP가 필요할 수 있음.
upcall은 커널이 사용자 스레드 라이브러리에 특정 이벤트를 알려주는 절차.
커널은 스레드 봉쇄나 실행 가능 상태 변화 같은 이벤트를 upcall을 통해 라이브러리에 알릴 수 있음.
사용자 스레드 라이브러리는 이 정보를 바탕으로 어떤 사용자 스레드를 가상 처리기 위에 실행할지 결정함.
스케줄러 액티베이션은 커널과 사용자 스레드 라이브러리 사이의 협력으로 스케줄링을 조정하는 방식.
운영체제 사례
운영체제 사례에서는 Windows와 Linux가 스레드를 어떻게 구현하는지 살펴봄.
Windows는 일대일 모델을 사용하여 사용자 스레드 하나를 커널 스레드 하나에 연결함.
Linux는 프로세스와 스레드를 엄격히 구분하기보다 태스크라는 개념을 사용함.
또한 clone 시스템 콜로 자원 공유 수준을 조절함.
Windows 스레드
Windows 응용은 프로세스로 실행됨.
각 프로세스는 하나 이상의 스레드를 가질 수 있음.
Windows는 일대일 모델을 사용함.
Windows 스레드는
스레드 ID, 레지스터 집합, 프로그램 카운터, 사용자 스택, 커널 스택, 개별 데이터 저장 영역을 포함함.
레지스터 집합, 스택, 개별 데이터 저장 영역은 스레드의 문맥을 구성함.
Windows는 ETHREAD, KTHREAD, TEB 자료구조를 사용하여 스레드를 관리함.
ETHREAD는 실행 스레드 블록.
KTHREAD는 커널 스레드 블록.
TEB는 스레드 환경 블록.
ETHREAD는 스레드가 속한 프로세스를 가리키는 포인터를 포함함.
또한 실행 시작 루틴의 주소를 포함함.
KTHREAD는 스케줄링 정보와 동기화 정보를 포함함.
또한 커널 스택과 TEB 포인터를 포함함.
TEB는 사용자 모드에서 접근되는 스레드 관련 정보를 저장함.
TEB에는 스레드 식별자, 사용자 모드 스택, 스레드 로컬 저장소를 위한 배열이 포함될 수 있음.
ETHREAD와 KTHREAD는 커널 공간에 존재함.
TEB는 사용자 공간에 존재함.
Linux 스레드
Linux는 프로세스와 스레드를 엄격하게 구분하지 않음.
Linux는 프로그램 안의 제어 흐름을 태스크라는 개념으로 표현함.
fork 시스템 콜은 새로운 프로세스를 생성하는 기능.
clone 시스템 콜은 부모 태스크와 자식 태스크가 어떤 자원을 공유할지 플래그로 지정할 수 있는 기능.
clone에 전달되는 플래그에 따라 메모리 공간을 공유할 수 있음.
열린 파일을 공유할 수도 있음.
신호 처리기를 공유할 수도 있음.
파일 시스템 정보를 공유할 수도 있음.
아무 플래그 없이 clone을 호출하면 fork와 비슷하게 동작함.
여러 자원을 공유하도록 호출하면 스레드 생성과 비슷한 결과가 됨.
Linux 커널은 각 태스크를 task_struct 자료구조로 표현함.
task_struct는 태스크의 모든 정보를 직접 저장하기보다 관련 자료구조를 가리키는 포인터를 포함함.
이 자료구조에는 열린 파일 목록, 신호 처리 정보, 가상 메모리 정보 등이 포함될 수 있음.
fork는 부모 프로세스의 관련 자료구조를 복사하여 새 태스크를 생성함.
clone은 플래그에 따라 자료구조를 복사하지 않고 공유하도록 만들 수 있음.
Linux 스레드 구현의 핵심은 태스크 개념과 clone 시스템 콜을 통해 자원 공유 수준을 조절하는 구조.
clone 시스템 콜은 컨테이너와 같은 격리 구조에도 확장될 수 있음.