Skip to content

Latest commit

 

History

History
297 lines (226 loc) · 12.9 KB

똑똑한 사람들은 멀티 쓰레드를 쓴다.md

File metadata and controls

297 lines (226 loc) · 12.9 KB

출처


개요

  • [[#병렬성의 직관성]]
  • [[#당연하지만 빠른 속도]]
  • [[#무거워서 멀티 쓰레드를 안써요]]
  • [[#정리]]

병렬성의 직관성

병행성과 병렬성에 대해 잠시 생각해보자. 일반적으로 생각해봤을 때 어떠한 작업을 병행적으로 진행하는 것보단 병렬로 진행하는 것이 당연히 효율적으로 느껴진다. 아래의 코드를 보자.

import asyncio
async def do1():
	do_something()
	await do2()
	finish...

async def do2():
	do_something()
	finish...

tasks = [asyncio.create_task(do1) for __ in range(10)]
await asyncio.gather(*tasks)

해당 함수의 실행 흐름을 생각해보면 do1 -> do2 -> do1의 순서로 진행된다. 이렇게 작성하면 실행 흐름을 추적하는 작업이 어려워진다. 엥 직관적으로 이해되는데 뭐가 파악이 어렵다는 건가요? 하지만 await로 인한 체이닝이 많아지고 구문이 많아질 수록 마치 promise 체인이 생기듯이 코드 구조가 복잡해지는 현상을 확인할 수 있다.

이는 인위적으로 블락킹이 발생하는 곳마다 await 키워드를 명시해야 하기 때문인데 이로 인해 실행흐름이 이벤트 루프로 반환돼며 흐름을 명쾌히 파악하는 것에 어려움이 발생한다. 예를 들어 do2 코루틴을 이벤트 루프에 동시에 10개 등록했다고 가정해보자. 여러 코루틴이 동시에 실행되고 await로 인한 블락킹이 여러 곳에서 발생하면 이벤트 루프가 다음에 실행할 코드가 어떤 것일지 예측하는 것은 쉽지 않다. 이러한 문제는 비동기로 연결되는 작업이 많을 수록 체이닝이 복잡해지며 더욱 문제가 발생한다는 것이다.

하지만 멀티 쓰레드로 이러한 작업을 수행하면 어떨까? 각 쓰레드 별로 다음 작업으로 어떤 작업이 행해질지는 명확하게 파악할 수 있다. 아래의 코드를 보자.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

// do1 함수: 간단한 작업을 수행
void do1(int thread_id) {
    printf("Thread %d: Executing do1\n", thread_id);
    sleep(1); // 작업 시뮬레이션을 위한 대기
}

// do2 함수: do1을 호출
void do2(int thread_id) {
    printf("Thread %d: Entering do2\n", thread_id);
    do1(thread_id);
    printf("Thread %d: Exiting do2\n", thread_id);
}

// 쓰레드에서 실행할 함수
void* thread_function(void* arg) {
    int thread_id = *(int*)arg; // 쓰레드 ID
    do2(thread_id);
    return NULL;
}

int main() {
    const int NUM_THREADS = 10; // 생성할 쓰레드 수
    pthread_t threads[NUM_THREADS];
    int thread_ids[NUM_THREADS];

    // 쓰레드 생성
    for (int i = 0; i < NUM_THREADS; i++) {
        thread_ids[i] = i + 1; // 쓰레드 ID를 1부터 시작
        if (pthread_create(&threads[i], NULL, thread_function, &thread_ids[i]) != 0) {
            perror("Failed to create thread");
            return 1;
        }
    }

    // 쓰레드가 종료될 때까지 대기
    for (int i = 0; i < NUM_THREADS; i++) {
        if (pthread_join(threads[i], NULL) != 0) {
            perror("Failed to join thread");
            return 1;
        }
    }

    printf("All threads have finished.\n");
    return 0;
}

각 쓰레드를 기준으로 보면 어떠한 작업이 이후에 행해질 것인지는 직관적으로 파악이 가능하다. 이는 쓰레딩의 경우 실행 흐름을 여러개로 쪼개서 사용해 작업 도중 스위칭이 발생하지 않기 때문으로 이점을 활용하면 직관적으로 실행흐름을 파악할 수 있다.

[!quote] 병렬성이 실행흐름을 파악하는데는 더욱 유리하다. 직관적으로 흘러가기 때문이다.


당연하지만 빠른 속도

비동기 싱글 쓰레드 방식은 연산 속도에 한해서는 멀티 쓰레딩을 따라 잡을 수가 없다. 연산은 CPU bound 작업이기 때문에 비동기 방식의 장점인 병행성을 전혀 활용하지 못하기 때문이다. 아래의 행렬 연산 예제를 통해서 비교해보자.

 #include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>

#define N 200  // 행렬 크기
#define THREADS 8  // 쓰레드 수

double A[N][N], B[N][N], C[N][N];

typedef struct {
    int start_row;
    int end_row;
} ThreadData;

// 행렬 곱셈 함수 (쓰레드용)
void* matrix_multiply(void* arg) {
    ThreadData* data = (ThreadData*)arg;
    for (int i = data->start_row; i < data->end_row; i++) {
        for (int j = 0; j < N; j++) {
            C[i][j] = 0;
            for (int k = 0; k < N; k++) {
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }
    return NULL;
}

int main() {
    srand(time(NULL));
    for (int i = 0; i < N; i++) {
        for (int j = 0; j < N; j++) {
            A[i][j] = rand() % 10 + 1;
            B[i][j] = rand() % 10 + 1;
        }
    }

    pthread_t threads[THREADS];
    ThreadData thread_data[THREADS];
    int rows_per_thread = N / THREADS;

    // 시간 측정 시작
    clock_t start_time = clock();

    // 쓰레드 생성
    for (int i = 0; i < THREADS; i++) {
        thread_data[i].start_row = i * rows_per_thread;
        thread_data[i].end_row = (i == THREADS - 1) ? N : (i + 1) * rows_per_thread;
        pthread_create(&threads[i], NULL, matrix_multiply, &thread_data[i]);
    }

    // 쓰레드 종료 대기
    for (int i = 0; i < THREADS; i++) {
        pthread_join(threads[i], NULL);
    }

    // 시간 측정 종료
    clock_t end_time = clock();

    printf("Time taken (multi-threaded, C): %.2f seconds\n", (double)(end_time - start_time) / CLOCKS_PER_SEC);

    return 0;
}
import time
import random


# 순수 Python 행렬 곱셈
def matrix_multiply(A, B, result):
    for i in range(len(A)):
        for j in range(len(B[0])):
            result[i][j] = sum(A[i][k] * B[k][j] for k in range(len(B)))


def main():
    N = 200  # 행렬 크기
    A = [[random.randint(1, 10) for _ in range(N)] for _ in range(N)]
    B = [[random.randint(1, 10) for _ in range(N)] for _ in range(N)]
    result = [[0 for _ in range(N)] for _ in range(N)]

    # 시간 측정 시작
    start_time = time.time()
    matrix_multiply(A, B, result)
    end_time = time.time()

    print(f"Time taken (single-threaded, Python): {end_time - start_time:.2f} seconds")


if __name__ == "__main__":
    main()

위의 두 코드는 모두 동일한 행렬 연산을 수행하는 코드이다. 파이썬 코드는 싱글 쓰레드 방식으로 동작하고 C 코드는 멀티 쓰레드 방식을 활용해 동작한다. 이때 실행 시간의 차이는 아래와 같이 발생한다.

[!quote] 연산이 많을 경우 멀티 쓰레드가 퍼포먼스가 높다.


무거워서 멀티 쓰레드를 안써요

멀티 쓰레딩의 단점을 이야기하면 반드시 등장하는 키워드들이 몇가지 있다.

  • 만들 수 있는 쓰레드의 수는 정해져 있다
  • 경량 쓰레드라고 하더라도 비동기의 테스크 객체보다 가벼울 수는 없다
  • 멀티 쓰레딩의 컨텍스트 스위칭 비용은 저렴하지 않다

이는 모두 맞는 말으로 우리는 해당 내용을 [[초 간단 웹서버 만들고 실험하기#멀티 쓰레드 VS 비동기|멀티 쓰레드 VS 비동기]] 에서 이미 다뤄봤다. 요청 수가 많아질 수록 한정된 쓰레드 수로 인한 블락킹이 많아지면서 IO가 많을 경우에는 블락킹이 발생할 위험이 적은 비동기가 더 유리하다는 내용으로 당시에는 결론이 났다.

하지만 이런 방식으로 작업을 처리하면 어떨까? 각 쓰레드 별로 이벤트 루프를 할당한다. 스케줄링 및 요청 분배 만을 담당하는 쓰레드가 한개 존재하고 스케줄링 쓰레드가 각 쓰레드의 이벤트 루프에 적절하게 요청을 적재한다고 생각해보자. 즉 우리가 흔히 말하는 비동기-싱글 쓰레드 방식을 병렬로 돌리면 어떤 일이 발생할 지에 대해 생각해 보자는 의견이다.

당연하지만 n배에 가까운 효율이 증가한다. 아래의 러스트 코드를 확인해보자. 우선 확인할 코드는 비동기- 싱글 쓰레드 방식으로 동작하는 러스트 코드이다.

use hyper::{Body, Request, Response, Server, Method};
use hyper::service::{make_service_fn, service_fn};
use tokio::time::{sleep, Duration};

async fn handle_request(req: Request<Body>) -> Result<Response<Body>, hyper::Error> {
    // 요청 메서드와 경로 확인
    match (req.method(), req.uri().path()) {
        // GET 요청에 대해 응답
        (&Method::GET, "/") => {
            sleep(Duration::from_secs(1)).await; // 1초 대기
            Ok(Response::new(Body::from("OK")))
        }
        // 다른 경로는 404 응답
        _ => Ok(Response::builder()
            .status(404)
            .body(Body::from("Not Found"))
            .unwrap()),
    }
}

#[tokio::main]
async fn main() {
    // 서버 주소
    let addr = ([127, 0, 0, 1], 3000).into();

    // 서비스 생성
    let make_svc = make_service_fn(|_conn| {
        async {
            Ok::<_, hyper::Error>(service_fn(handle_request))
        }
    });

    // 서버 생성 및 실행
    let server = Server::bind(&addr).serve(make_svc);

    println!("Server running on http://{}", addr);

    // 서버 실행 (에러 처리 포함)
    if let Err(e) = server.await {
        eprintln!("Server error: {}", e);
    }
}

해당 코드에 요청을 100건 정도 멀티 쓰레딩을 통해 처리했을 경우 성능은 다음과 같다.

총 4초 정도 소요된 것을 확인할 수 있다. 이제 멀티 쓰레딩 + 이벤트 루프 방식의 퍼포먼스를 확인해보자. 어떨까 큰 차이가 발생할까?

유의미하게 차이가 발생한 것을 확인할 수 있다. 물론 쓰레드의 수만큼 급격한 성능 개선이 발생하진 않았지만, 분명하게도 성능에 개선이 발생했다는 것은 사실이다.

이러한 부분에서 우리가 얻어야하는 인사이트는 다음과 같다. 비동기-싱글 쓰레드와 멀티 쓰레딩은 상호 대척점에 있는 관계가 아니다. 굳이 둘중에 하나만 택할 필요가 없고 만약 높은 속도가 필요하다면 멀티 쓰레딩은 필연적으로 고려할 수 밖에 없다.

그렇다면 이제 다시 무거워서 멀티 쓰레딩을 안쓴다는 이야기로 돌아와보자. 멀티 쓰레딩이 무거운 것은 사실이다. 메모리와 CPU 사용량도 만만치 않고 부담스러운 것도 사실이다. 다만 정정해야 할 부분은 멀티 쓰레딩과 비동기-싱글 쓰레드가 양자택일의 관계여서 비동기를 택하는 것이 아니고 비동기로 충분하기 때문에 비동기를 택하는 것이라는 점이다.

[!summary] 멀티 쓰레딩은 적용할지 말지를 고려해야지 비동기-싱글 쓰레드를 택했기 때문에 사용하지 못한다는 말은 어불성설이다.


정리

우리는 다음의 인사이트를 얻었다.

  • 멀티 쓰레딩을 사용하면 빠르다.
  • 멀티 쓰레딩은 계산에 유리하다
  • 멀티 쓰레딩은 코루틴이나 비동기 테스크 객체에 비하면 무거운 편이다
  • 쓰레드를 엄청나게 많이 만드는 것은 어렵다

멀티 쓰레드를 사용하면 병렬성을 갖게 되고 직관적인 코드 흐름을 확보할 수 있다. 하지만 이에 따라 메모리와 CPU 사용률이 증가하고 스위칭 비용의 증대라는 주요한 이슈도 발생하게 된다. 따라서 멀티 쓰레드를 적용하기 전에는 어느 정도의 퍼포먼스 향상이 필요한지 고려하고 선택하는 것이 중요하다.

우리 서비스는 대규모 IO가 위주니까 비동기 - 싱글 쓰레드를 활용해 구축하고 노드로 짜야지 하지만 성능 향상이 필요하다면? 얼마든지 멀티 쓰레드를 적용하거나 멀티 프로세싱을 적용해 개선할 수 있다.

+)rust 이야기 잠깐