Computer >> 컴퓨터 >  >> 프로그래밍 >> C 프로그래밍

암시적 스레딩과 언어 기반 스레딩: OpenMP부터 Go·Rust까지


암시적 스레딩(Implicit Threading)

멀티스레드 애플리케이션 설계의 어려움을 해결하고 개발을 더욱 수월하게 만드는 한 가지 방법은, 스레드의 생성과 관리를 애플리케이션 개발자가 아닌 컴파일러와 런타임 라이브러리에 맡기는 것입니다. 이를 암시적 스레딩(implicit threading)이라고 하며, 오늘날 널리 사용되는 추세입니다.

암시적 스레딩은 주로 라이브러리나 언어 차원의 지원을 활용해 스레드 관리 작업을 숨기는 방식입니다. C 언어 환경에서 가장 널리 쓰이는 암시적 스레딩 라이브러리는 OpenMP입니다.

OpenMP는 C, C++, FORTRAN으로 작성된 프로그램을 위한 컴파일러 지시문(compiler directive)과 API의 집합으로, 공유 메모리 환경에서의 병렬 프로그래밍을 지원합니다. OpenMP는 병렬로 실행될 수 있는 코드 블록을 '병렬 영역(parallel region)'으로 식별합니다. 개발자는 병렬 영역에 컴파일러 지시문을 삽입하고, 이 지시문은 OpenMP 런타임 라이브러리에게 해당 영역을 병렬로 실행하도록 지시합니다. 다음 C 프로그램은 printf() 문이 포함된 병렬 영역 위에 컴파일러 지시문을 배치한 예입니다.

예제

#include <omp.h>
#include <stdio.h>
int main(int argc, char *argv[]){
    /* 순차 코드 */
    #pragma omp parallel{
        printf("I am a parallel region.");
    }
    /* 순차 코드 */
    return 0;
}

실행 결과

I am a parallel region.

OpenMP가 다음 지시문을 만나면,

#pragma omp parallel

시스템의 처리 코어 수만큼 스레드를 생성합니다. 따라서 듀얼 코어 시스템에서는 두 개, 쿼드 코어 시스템에서는 네 개의 스레드가 생성되는 식입니다. 이후 모든 스레드가 동시에 병렬 영역을 실행하며, 각 스레드는 병렬 영역을 벗어나면 종료됩니다. OpenMP는 루프 병렬화를 포함해 코드 영역을 병렬로 실행하기 위한 다양한 추가 지시문도 제공합니다.

병렬화 지시문 외에도 OpenMP는 개발자가 여러 수준의 병렬성을 선택할 수 있게 해줍니다. 예를 들어 스레드 수를 직접 설정할 수 있으며, 데이터가 스레드 간에 공유되는지 아니면 특정 스레드에 비공개(private)인지 지정할 수도 있습니다. OpenMP는 Linux, Windows, macOS용 오픈소스 및 상용 컴파일러 대부분에서 사용할 수 있습니다.

Grand Central Dispatch(GCD)

Grand Central Dispatch(GCD)는 Apple의 macOS와 iOS 운영체제를 위한 기술로, C 언어 확장, API, 런타임 라이브러리의 결합체입니다. GCD를 사용하면 애플리케이션 개발자가 병렬로 실행할 코드 섹션을 손쉽게 지정할 수 있습니다. OpenMP와 마찬가지로 GCD 역시 스레딩의 대부분 세부 사항을 대신 관리해 줍니다. GCD는 '블록(block)'이라 불리는 C/C++ 언어 확장을 도입했습니다. 블록은 독립적으로 실행 가능한 작업 단위이며, 중괄호 { } 앞에 캐럿(^) 기호를 붙여 표현합니다. 간단한 블록의 예는 다음과 같습니다.

^{ printf("This is a block"); }

GCD는 블록을 디스패치 큐(dispatch queue)에 배치하여 런타임 실행을 스케줄링합니다. GCD가 큐에서 블록을 꺼내면, 자신이 관리하는 스레드 풀에서 사용 가능한 스레드에 해당 블록을 할당합니다. 디스패치 큐에는 직렬(serial)동시(concurrent) 두 가지 유형이 있습니다.

직렬 큐에 배치된 블록은 FIFO 순서로 제거됩니다. 블록이 큐에서 제거되면 다음 블록이 제거되기 전에 해당 블록의 실행이 완료되어야 합니다. 각 프로세스에는 자신만의 직렬 큐(메인 큐)가 있으며, 개발자는 특정 프로세스에 종속된 추가 직렬 큐를 생성할 수도 있습니다. 직렬 큐는 여러 작업의 순차적 실행을 보장해야 할 때 유용합니다.

동시 큐에 배치된 블록 역시 FIFO 순서로 제거되지만, 한 번에 여러 블록이 제거될 수 있으므로 여러 블록이 병렬로 실행될 수 있습니다. 시스템 전역 동시 디스패치 큐는 세 개가 있으며, 낮음·기본·높음의 우선순위에 따라 구분됩니다. 우선순위는 블록의 상대적 중요도를 나타내며, 우선순위가 높은 블록일수록 높은 우선순위 디스패치 큐에 배치하는 것이 좋습니다. 다음 코드는 기본 우선순위의 동시 큐를 얻어 dispatch_async() 함수로 블록을 제출하는 예입니다.

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(queue, ^{ printf("This is a block."); });

내부적으로 GCD의 스레드 풀은 POSIX 스레드로 구성됩니다. GCD는 스레드 풀을 능동적으로 관리하여, 애플리케이션의 요구량과 시스템 용량에 따라 스레드 수를 유연하게 늘리거나 줄입니다.

객체로서의 스레드(Threads as Objects)

다른 접근 방식으로, 일부 객체지향 언어들은 스레드를 객체로 다루는 명시적 멀티스레딩 지원을 제공합니다. 이러한 언어에서는 클래스가 스레드 클래스를 상속하거나 해당 인터페이스를 구현하도록 작성됩니다. 이 스타일은 명시적인 스레드 관리 코드를 작성한다는 점에서 Pthread 방식과 유사하지만, 클래스 내부의 정보 캡슐화와 추가 동기화 기능 덕분에 작업이 한결 수월해집니다.

Java 스레드

Java는 Thread 클래스와 Runnable 인터페이스를 제공합니다. 두 방식 모두 스레드의 진입점(entry point)을 정의하는 public void run() 메서드를 구현해야 합니다. 객체 인스턴스가 생성된 후에는 start() 메서드를 호출하여 스레드를 시작합니다. Pthreads와 마찬가지로 스레드 시작은 비동기적으로 이루어지므로, 실행 시점은 결정적이지 않습니다(non-deterministic).

Python 스레드

Python 역시 멀티스레딩을 위한 두 가지 메커니즘을 제공합니다. 첫 번째 방식은 함수 이름을 라이브러리 메서드인 thread.start_new_thread()에 전달하는 것으로, Pthread 스타일과 유사합니다. 이 방식은 매우 단순하지만, 스레드가 시작된 후 조인(join)하거나 종료할 수 있는 유연성이 부족합니다. 더 유연한 방법은 threading 모듈을 사용해 threading.Thread를 상속하는 클래스를 정의하는 것입니다. Java 방식과 마찬가지로 이 클래스는 스레드의 진입점 역할을 하는 run() 메서드를 가져야 하며, 객체를 인스턴스화한 후 명시적으로 시작(start)하고 조인(join)할 수 있습니다.

언어 설계로서의 동시성(Concurrency as Language Design)

최신 프로그래밍 언어들은 동시 실행에 대한 가정을 언어 설계 자체에 직접 반영함으로써 경쟁 상태(race condition)를 예방하고자 했습니다. 예를 들어 Go는 매우 간단한 암시적 스레딩 메커니즘인 고루틴(goroutine)과, 잘 정의된 메시지 전달 통신 방식인 채널(channel)을 결합합니다. Rust는 Pthreads와 유사한 명시적 스레딩 방식을 채택했지만, 프로그래머가 별도의 노력 없이도 강력한 메모리 보호 기능의 혜택을 누릴 수 있습니다.

고루틴(Goroutines)

Go 언어에는 놀랍도록 간단한 암시적 스레딩 메커니즘이 내장되어 있습니다. 바로 함수 호출 앞에 go 키워드를 붙이는 것입니다. 새 스레드에는 메시지 전달 채널에 대한 참조가 전달되며, 메인 스레드는 success := <-messages를 호출해 채널에 대한 차단 읽기(blocking read)를 수행합니다. 사용자가 올바른 값 7을 입력하면 키보드 감시 스레드가 채널에 값을 쓰고, 이를 통해 메인 스레드가 계속 진행될 수 있습니다.

채널과 고루틴은 Go 언어의 핵심 구성 요소로, Go는 대부분의 프로그램이 멀티스레드로 실행될 것이라는 가정 아래 설계되었습니다. 이러한 설계 선택 덕분에 개발 모델이 크게 간소화되며, 언어 자체가 스레드 관리와 스케줄링에 대한 책임을 짊어지게 됩니다.

Rust 동시성

Rust는 최근 몇 년 사이 등장한 또 다른 언어로, 동시성을 핵심 설계 특징으로 삼았습니다. 다음 예제는 thread::spawn()으로 새 스레드를 생성하고, 이후 join()을 호출해 조인하는 방법을 보여줍니다. thread::spawn()의 인수에서 ||로 시작하는 부분은 클로저(closure)라고 하며, 익명 함수라고 생각할 수 있습니다. 즉, 여기서 자식 스레드는 a의 값을 출력하게 됩니다.

예제

use std::thread;
fn main() {
    /* 변경 가능한 변수 a를 7로 초기화 */
    let mut a = 7;
    /* 새 스레드 생성 */
    let child_thread = thread::spawn(move || {
        /* 1초 대기 후 a 출력 */
        a -= 1;
        println!("a = {}", a)
    });
    /* 메인 스레드에서 a 변경 후 출력 */
    a += 1;
    println!("a = {}", a);
    /* 스레드 조인 후 a 재출력 */
    child_thread.join();
}

그러나 이 코드에는 Rust 설계의 핵심과 관련된 미묘하지만 중요한 포인트가 숨어 있습니다. 새 스레드(클로저 내부의 코드) 안에서 변수 a는 코드의 다른 부분에 있는 a와 완전히 별개입니다. Rust는 여러 스레드가 동일한 메모리에 접근하는 것을 원천적으로 차단하는 매우 엄격한 메모리 모델, 이른바 '소유권(ownership)' 모델을 강제합니다. 이 예제에서 move 키워드는 생성된 스레드가 자신만의 a 복사본을 받아 사용하게 됨을 의미합니다. 따라서 두 스레드의 스케줄링 순서와 무관하게, 메인 스레드와 자식 스레드는 서로 다른 복사본을 가지므로 a에 대한 수정이 서로 간섭할 수 없습니다. 즉, 두 스레드가 동일한 메모리에 대한 접근을 공유하는 것은 애초에 불가능합니다.