리눅스 커널 워크큐 (Workqueue): 바운드(bound) vs. 언바운드(unbound)

리눅스 커널은 다양한 비동기 작업을 효율적으로 처리하고 관리하기 위한 메커니즘을 제공하는데 그 중 하나가 워크큐(Workqueue)이다. 워크큐는 바운드(bound) 워크큐와 언바운드(unbound) 워크큐 두 가지 주요 유형으로 구분된다. 각각의 워크큐 유형은 특정 상황과 요구 사항에 따라 선택되며, 이에 대한 이해는 리눅스 커널 개발과 시스템 최적화에 필수적이다.

바운드 워크큐 (Bound Workqueue)

바운드 워크큐는 CPU마다 독립된 워커 풀(worker pool)을 두고, 작업을 큐에 넣은 CPU의 워커 풀이 그 작업을 처리하는 워크큐다. 여기서 주의할 점은 “특정 CPU에 고정”된다는 말이 워크큐 전체가 하나의 CPU에만 항상 묶인다는 뜻이 아니라는 것이다. queue_work()는 호출 시점의 CPU에 작업을 큐잉하고(CPU가 다운되면 다른 CPU로 넘어간다), queue_work_on()을 쓰면 CPU를 직접 지정할 수도 있다. 즉 작업을 큐잉한 CPU와 처리하는 CPU가 같기 때문에 캐시 활용이 유리한 것이지, 워크큐가 어느 한 CPU에 못박혀 있는 게 아니다. 바운드 워크큐의 주요 특징은 다음과 같다.

장점:

  • 캐시 효율성: 작업을 큐잉한 CPU에서 그대로 처리하므로 해당 CPU의 캐시를 효율적으로 활용할 수 있다. 이로 인해 메모리 액세스가 빠른 장점이 있다.
  • 프로세서 어피니티: 바운드 워크큐를 사용하면 워크큐의 실행을 특정 CPU에 제한할 수 있으므로 멀티코어 시스템에서 프로세서 어피니티(Processor Affinity)를 활용할 수 있다.

단점:

  • CPU 부하 분산: 바운드 워크큐가 특정 CPU에 바인딩되므로, CPU 부하가 균등하게 분산되지 않을 수 있기 때문에, 일부 CPU는 과부하 상태에 놓일 수 있다.
  • 병목 현상: 바운드 워크큐에서 발생하는 병목 현상은 특정 CPU에서 워크큐 작업을 처리하는 동안 다른 CPU는 대기해야 하는 경우가 생기는데 이로 인한 병목 현상으로 성능 저하를 가져올 수 있다.

언바운드 워크큐 (Unbound Workqueue)

언바운드 워크큐는 특정 CPU에 바인딩되지 않고 시스템 전체에서 사용 가능한 워크큐이다. 이러한 워크큐는 어떤 CPU에서든 실행될 수 있어 CPU 부하가 고르게 분산될 수 있다. 언바운드 워크큐의 주요 특징은 다음과 같다.

장점:

  1. 부하 분산: 언바운드 워크큐는 어떤 CPU에서든 실행될 수 있으므로 CPU 부하가 균등하게 분산된다. 이로 인해 시스템 전체의 성능이 향상될 수 있다.
  2. 병렬 처리: 여러 CPU에서 언바운드 워크큐 작업을 동시에 실행할 수 있으므로 병렬 처리가 가능하다.

단점:

  1. 캐시 효율성 감소: 언바운드 워크큐는 특정 CPU에 바인딩되지 않으므로 캐시 효율성이 감소할 수 있다. 이로 인해 메모리 액세스가 느려질 수 있다.
  2. 프로세서 어피니티 부족: 언바운드 워크큐를 사용할 경우 프로세서 어피니티를 활용하기 어려울 수 있으며, 일부 작업에서는 이로 인한 성능 저하가 발생할 수 있다.

워크 큐 생성과 제거

create_workqueue()create_singlethread_workqueue()alloc_workqueue() 함수를 사용하여 workqueue를 생성한다.

#define alloc_ordered_workqueue(fmt, flags, args...)                    \
        alloc_workqueue(fmt, WQ_UNBOUND | __WQ_ORDERED | (flags), 1, ##args)

#define create_workqueue(name)                                          \
        alloc_workqueue("%s", __WQ_LEGACY | WQ_MEM_RECLAIM, 1, (name))
#define create_freezable_workqueue(name)                                \
        alloc_workqueue("%s", __WQ_LEGACY | WQ_FREEZABLE | WQ_UNBOUND | \
                        WQ_MEM_RECLAIM, 1, (name))
#define create_singlethread_workqueue(name)                             \
        alloc_ordered_workqueue("%s", __WQ_LEGACY | WQ_MEM_RECLAIM, name)

여기서 핵심은 WQ_UNBOUND 플래그의 유무다. create_workqueue()는 이 플래그가 없으니 bound, create_freezable_workqueue()는 있으니 unbound다. 그리고 이름과 달리 create_singlethread_workqueue()는 “스레드 하나”를 의미하는 게 아니라 alloc_ordered_workqueue()를 호출해 WQ_UNBOUND | __WQ_ORDERED 조합, 즉 unbound이면서 max_active=1로 작업을 순서대로 하나씩만 처리하는 워크큐를 만든다. 매크로의 정확한 플래그 구성은 커널 버전마다 조금씩 다를 수 있으니 실제로 확인할 때는 빌드에 쓰는 커널의 include/linux/workqueue.h를 기준으로 삼는다.

alloc_workqueue()는 다음과 같은 파라미터를 갖는다.

  • fmt – workqueue의 이름을 출력하는 printf 포멧
  • flags – WQ_* 플래그
    • WQ_UNBOUND
    • WQ_FREEZABLE
    • WQ_MEM_RECLAIM
    • WQ_HIGHPRI
    • WQ_CPU_INTENSIVE
  • max_active – CPU당 동시에 실행 가능한 작업 항목 수의 상한. 0을 넘긴다고 동시 실행이 0개(비활성화)라는 뜻이 아니다 — 커널이 정한 기본값(WQ_DFL_ACTIVE)이 대신 적용된다.
  • name – workqueue 이름

바운드 워크큐 예제

다음은 바운드 워크큐를 생성하는 예제 코드이다.

#include <linux/init.h>
#include <linux/module.h>
#include <linux/workqueue.h>

static struct workqueue_struct *my_workqueue;

// 워크큐에서 실행될 작업 핸들러 함수
static void my_work_handler(struct work_struct *work)
{
    // 워크큐 작업 내용을 여기에 작성
    printk(KERN_INFO "My bound workqueue is running on CPU %d\n", raw_smp_processor_id());
}

// 워크큐 작업을 정의합니다.
static DECLARE_WORK(my_work, my_work_handler);

// 모듈 초기화 함수
static int __init my_module_init(void)
{
    // 바운드 워크큐 생성
    my_workqueue = create_workqueue("my_bound_workqueue");
    if (!my_workqueue)
    {
        printk(KERN_ERR "Failed to create workqueue\n");
        return -ENOMEM;
    }

    // 워크큐에 작업 추가 (현재 CPU에 큐잉된다)
    queue_work(my_workqueue, &my_work);

    printk(KERN_INFO "Bound workqueue module initialized\n");
    return 0;
}

// 모듈 정리 함수
static void __exit my_module_exit(void)
{
    // 워크큐 정리
    flush_workqueue(my_workqueue);
    destroy_workqueue(my_workqueue);

    printk(KERN_INFO "Bound workqueue module exited\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Bound Workqueue Module");
sudo insmod bound_wq.ko
sudo rmmod bound_wq
[ 2502.294477] Bound workqueue module initialized
[ 2502.294481] My bound workqueue is running on CPU 0
...
[ 2562.599724] Bound workqueue module exited

여기서 CPU 0은 insmod를 실행한 셸이 그 순간 CPU 0에서 돌고 있었다는 뜻일 뿐이다. 같은 모듈을 다른 CPU에서 로드하면 로그에 찍히는 CPU 번호도 그만큼 달라진다. 또한 smp_processor_id() 대신 raw_smp_processor_id()를 쓴 이유는, 프리엠션(preemption)이 켜진 구간에서 smp_processor_id()를 호출하면 CONFIG_DEBUG_PREEMPT 커널에서 “using smp_processor_id() in preemptible code” 경고가 뜰 수 있기 때문이다. 단순 로깅 목적이라면 raw_smp_processor_id()가 더 안전하다.

언바운드 워크큐 예제 — 병렬 실행

언바운드 워크큐가 실제로 여러 CPU에서 동시에 실행되는 모습을 보려면 작업(work item)을 여러 개 큐잉해야 한다. 다음은 WQ_UNBOUND 플래그로 워크큐를 만들고 작업 4개를 큐잉하는 예제 코드이다.


#include <linux/init.h>
#include <linux/module.h>
#include <linux/workqueue.h>

#define NUM_WORKS 4

static struct workqueue_struct *my_workqueue;
static struct work_struct my_works[NUM_WORKS];

// 워크큐에서 실행될 작업 핸들러 함수
static void my_work_handler(struct work_struct *work)
{
    printk(KERN_INFO "Unbound workqueue work item running on CPU %d\n",
           raw_smp_processor_id());
}

// 모듈 초기화 함수
static int __init my_module_init(void)
{
    int i;

    // WQ_UNBOUND: 워커가 특정 CPU에 묶이지 않는다. max_active=0 -> 커널 기본값 사용
    my_workqueue = alloc_workqueue("my_unbound_workqueue", WQ_UNBOUND, 0);
    if (!my_workqueue)
    {
        printk(KERN_ERR "Failed to create workqueue\n");
        return -ENOMEM;
    }

    for (i = 0; i < NUM_WORKS; i++)
    {
        INIT_WORK(&my_works[i], my_work_handler);
        queue_work(my_workqueue, &my_works[i]);
    }

    printk(KERN_INFO "Unbound workqueue module initialized\n");
    return 0;
}

// 모듈 정리 함수
static void __exit my_module_exit(void)
{
    flush_workqueue(my_workqueue);
    destroy_workqueue(my_workqueue);

    printk(KERN_INFO "Unbound workqueue module exited\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Unbound Workqueue Module (Parallel)");
sudo insmod unbound_wq.ko
sudo rmmod unbound_wq

(아래는 4코어 시스템에서 실행했을 때 나올 법한 예시 출력이다. 스케줄러 상태에 따라 실행 순서와 CPU 번호는 매번 달라질 수 있다.)

[ 3011.102233] Unbound workqueue module initialized
[ 3011.102240] Unbound workqueue work item running on CPU 1
[ 3011.102241] Unbound workqueue work item running on CPU 3
[ 3011.102243] Unbound workqueue work item running on CPU 0
[ 3011.102245] Unbound workqueue work item running on CPU 2
...
[ 3050.611298] Unbound workqueue module exited

바운드 예제의 출력과 비교해보면, 4개의 작업이 각기 다른 CPU에서 동시에 처리된다. 이게 언바운드 워크큐의 “병렬 처리” 장점이 실제로 나타나는 모습이다.

순서 보장이 필요할 때 — create_singlethread_workqueue()

반대로 “여러 CPU에서 실행되더라도 작업 순서는 절대 뒤섞이면 안 되는” 경우에는 create_singlethread_workqueue()를 쓴다. 앞서 봤듯 이 함수는 내부적으로 WQ_UNBOUND | __WQ_ORDERED 조합이라 CPU에 고정되지는 않지만 max_active=1이라 한 번에 작업 하나만 처리한다. 디바이스 상태 전이처럼 순서가 꼬이면 안 되는 로직을 워크큐로 처리할 때 적합하다.

#include <linux/init.h>
#include <linux/module.h>
#include <linux/workqueue.h>

static struct workqueue_struct *my_workqueue;

// 워크큐에서 실행될 작업 핸들러 함수
static void my_work_handler(struct work_struct *work)
{
    // 워크큐 작업 내용을 여기에 작성
    printk(KERN_INFO "Ordered workqueue is running on CPU %d\n", raw_smp_processor_id());
}

// 워크큐 작업을 정의합니다.
static DECLARE_WORK(my_work, my_work_handler);

// 모듈 초기화 함수
static int __init my_module_init(void)
{
    // 순서 보장 워크큐 생성 (unbound + max_active=1)
    my_workqueue = create_singlethread_workqueue("my_ordered_workqueue");
    if (!my_workqueue)
    {
        printk(KERN_ERR "Failed to create workqueue\n");
        return -ENOMEM;
    }

    // 워크큐에 작업 추가
    queue_work(my_workqueue, &my_work);

    printk(KERN_INFO "Ordered workqueue module initialized\n");
    return 0;
}

// 모듈 정리 함수
static void __exit my_module_exit(void)
{
    // 워크큐 정리
    flush_workqueue(my_workqueue);
    destroy_workqueue(my_workqueue);

    printk(KERN_INFO "Ordered workqueue module exited\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Ordered (Unbound, max_active=1) Workqueue Module");
[ 3102.881120] Ordered workqueue module initialized
[ 3102.881126] Ordered workqueue is running on CPU 2
...
[ 3150.203981] Ordered workqueue module exited

CPU 번호가 바운드 예제와 다를 수 있는 건 unbound라서 그렇고, 작업을 여러 개 큐잉해도 항상 하나씩 순서대로만 처리되는 게 이 워크큐의 핵심이다.

결론

어떤 워크큐를 선택해야 할지는 사용 사례와 시스템 요구 사항에 따라 다르다. 바운드 워크큐는 작업을 큐잉한 CPU의 캐시 효율성이 중요한 작업에 적합하고, 언바운드 워크큐(alloc_workqueue(..., WQ_UNBOUND, ...))는 CPU 부하 분산과 병렬 처리가 중요한 경우에 적합하다. 그리고 병렬성이 아니라 순서 보장이 필요하다면 create_singlethread_workqueue()(ordered workqueue)를 쓴다. 이 세 가지를 구분해서 선택하고 구성하는 것이 워크큐를 이용한 시스템 최적화의 핵심이다.

참고:

답글 남기기