C++ 用户态与内核态线程调度:理解操作系统的调度策略

C++ 用户态与内核态线程调度:一场线程的“宫斗戏”

各位观众,大家好!今天咱们来聊聊C++里线程调度这档子事儿。这就像后宫佳丽三千,皇上(操作系统)决定今天宠幸谁,明天又翻谁的牌子。只不过,这里的“佳丽”是线程,而“皇上”是操作系统内核。

咱们先捋捋清楚,啥是用户态线程,啥是内核态线程,它们之间又有什么爱恨情仇。

第一幕:角色登场——用户态线程 vs. 内核态线程

  • 内核态线程(Kernel-Level Thread,KLT): 这位可是皇家的正统血脉,由操作系统内核直接管理。创建、销毁、调度都由内核一手包办。Linux的pthread库创建的线程,基本上都是内核态线程。每个KLT都有自己的内核线程控制块(TCB),内核直接维护这些TCB。

  • 用户态线程(User-Level Thread,ULT): 这位就有点像“私生子”,它不是由内核直接管理,而是由用户程序自己维护的。用户程序自己实现线程库,负责线程的创建、销毁、调度。这就像一个公司内部自己搞了一套线程管理系统,老板(用户程序)说了算。

用一张表来总结一下:

特性 内核态线程 (KLT) 用户态线程 (ULT)
管理者 操作系统内核 用户程序
创建销毁 内核调用 用户程序调用
调度者 操作系统内核 用户程序实现的线程库
切换代价 较高,需要陷入内核态 较低,无需陷入内核态
阻塞影响 一个线程阻塞,不会影响其他线程运行,只要别的线程没被阻塞。 一个线程阻塞,整个进程都会阻塞(除非使用异步IO等技巧)
资源占用 每个线程都有独立的内核资源,占用较多 资源占用较少,依赖于进程的资源

第二幕:内核态线程的“皇权特许”

咱们先看看内核态线程的调度。这部分比较简单,因为一切都由内核说了算。

调度策略:

操作系统内核通常会采用一些经典的调度算法,比如:

  • 先来先服务(FCFS): 谁先来,谁先执行。公平是公平,但效率不高,容易出现“长作业霸占CPU”的情况。
  • 短作业优先(SJF): 谁执行时间短,谁先执行。能有效提高吞吐量,但需要预先知道每个线程的执行时间,不太现实。
  • 优先级调度: 给每个线程分配一个优先级,优先级高的先执行。优先级可以静态设置,也可以动态调整。
  • 时间片轮转(RR): 给每个线程分配一个时间片,用完就切换到下一个线程。保证公平性,但时间片过短会增加上下文切换的开销。
  • 多级反馈队列(MLFQ): 这是个比较复杂的调度算法,它结合了优先级调度和时间片轮转。有多个就绪队列,每个队列的优先级不同,时间片大小也不同。线程根据运行情况在不同队列之间移动。

代码示例(模拟内核态线程调度):

虽然我们不能直接模拟内核的调度,但可以用C++模拟一个简单的优先级调度器:

#include <iostream>
#include <queue>
#include <vector>
#include <thread>
#include <chrono>
#include <mutex>
#include <condition_variable>

using namespace std;

// 线程结构体
struct Thread {
    int id;
    int priority;
    int runtime; // 线程需要运行的时间
    bool finished = false;

    Thread(int id, int priority, int runtime) : id(id), priority(priority), runtime(runtime) {}
};

// 自定义比较函数,用于优先级队列
struct CompareThreads {
    bool operator()(Thread const& t1, Thread const& t2) {
        // 优先级高的线程排在前面
        return t1.priority < t2.priority;
    }
};

class SimpleScheduler {
public:
    SimpleScheduler() {}

    // 添加线程
    void addThread(Thread thread) {
        lock_guard<mutex> lock(queue_mutex);
        thread_queue.push(thread);
        cv.notify_one(); // 通知调度器有新线程加入
    }

    // 调度线程
    void run() {
        while (true) {
            unique_lock<mutex> lock(queue_mutex);
            cv.wait(lock, [this] { return !thread_queue.empty() || stop_scheduler; }); // 等待队列非空或停止信号

            if (stop_scheduler && thread_queue.empty()) {
                break; // 停止调度器
            }

            Thread current_thread = thread_queue.top();
            thread_queue.pop();
            lock.unlock(); // 释放锁,允许其他线程添加任务

            cout << "Running thread " << current_thread.id << " with priority " << current_thread.priority << endl;

            // 模拟线程运行
            this_thread::sleep_for(chrono::milliseconds(current_thread.runtime * 100));

            lock_guard<mutex> lock2(queue_mutex);
            cout << "Thread " << current_thread.id << " finished." << endl;
            current_thread.finished = true;

            if (thread_queue.empty()) {
                stop_scheduler = true;
            }
        }
        cout << "Scheduler stopped." << endl;
    }

    void stop() {
        {
            lock_guard<mutex> lock(queue_mutex);
            stop_scheduler = true;
        }
        cv.notify_one();
    }

private:
    priority_queue<Thread, vector<Thread>, CompareThreads> thread_queue; // 优先级队列
    mutex queue_mutex; // 互斥锁,保护队列
    condition_variable cv; // 条件变量,用于线程同步
    bool stop_scheduler = false;
};

int main() {
    SimpleScheduler scheduler;

    // 创建线程
    Thread t1(1, 1, 5); // ID 1, 优先级 1, 运行时间 5
    Thread t2(2, 3, 2); // ID 2, 优先级 3, 运行时间 2
    Thread t3(3, 2, 3); // ID 3, 优先级 2, 运行时间 3

    // 启动调度器
    thread scheduler_thread(&SimpleScheduler::run, &scheduler);

    // 添加线程到调度器
    scheduler.addThread(t1);
    scheduler.addThread(t2);
    scheduler.addThread(t3);

    // 等待调度器完成
    scheduler_thread.join();

    return 0;
}

代码解释:

  • Thread 结构体: 表示一个线程,包含ID、优先级、运行时间等信息。
  • CompareThreads 结构体: 用于优先级队列的比较函数,优先级高的排在前面。
  • SimpleScheduler 类: 模拟一个简单的优先级调度器。
    • addThread(): 添加线程到优先级队列。
    • run(): 调度线程,从队列中取出优先级最高的线程执行,直到队列为空。使用互斥锁和条件变量保证线程安全。
  • main() 函数: 创建线程并添加到调度器,启动调度器线程。

这个代码仅仅是个演示,真实的内核调度远比这复杂得多,涉及到中断处理、上下文切换等底层操作。

上下文切换:

当内核决定切换到另一个线程时,需要保存当前线程的状态(CPU寄存器、程序计数器等),然后加载下一个线程的状态。这个过程称为上下文切换,是非常耗时的操作。所以,频繁的上下文切换会降低系统性能。

第三幕:用户态线程的“夹缝求生”

用户态线程的调度就比较复杂了。因为内核根本不知道用户态线程的存在,它只知道进程的存在。用户态线程的调度完全由用户程序自己控制。

调度策略:

用户程序可以实现各种各样的调度策略,比如:

  • 合作式调度(Cooperative Scheduling): 每个线程主动放弃CPU,让给其他线程。如果一个线程一直不放弃CPU,其他线程就永远无法执行。这种调度方式简单,但容易出现“线程饿死”的情况。
  • 抢占式调度(Preemptive Scheduling): 线程库使用定时器中断,强制切换线程。这种调度方式更公平,但也更复杂。

代码示例(用户态线程库):

下面是一个简单的用户态线程库的示例,使用合作式调度:

#include <iostream>
#include <vector>
#include <queue>
#include <functional>
#include <setjmp.h>
#include <signal.h>
#include <unistd.h>
#include <cstring>

using namespace std;

// 线程栈大小
const int STACK_SIZE = 1024 * 1024;

// 线程状态
enum ThreadState {
    READY,
    RUNNING,
    BLOCKED,
    FINISHED
};

// 线程控制块 (TCB)
struct ThreadContext {
    jmp_buf jmp_buf; // 保存线程的上下文
    ThreadState state;
    void* stack; // 线程栈
    function<void()> task; // 线程执行的任务
};

// 线程库
class UserThreadLibrary {
public:
    UserThreadLibrary() {
        // 初始化主线程
        ThreadContext main_context;
        main_context.state = RUNNING;
        main_context.stack = nullptr; // 主线程使用系统栈
        threads.push_back(main_context);
        current_thread_id = 0;
    }

    // 创建线程
    int createThread(function<void()> task) {
        ThreadContext new_context;
        new_context.state = READY;
        new_context.stack = malloc(STACK_SIZE); // 分配线程栈
        new_context.task = task;

        // 初始化线程栈
        char* stack_top = (char*)new_context.stack + STACK_SIZE;
        memset(new_context.stack, 0, STACK_SIZE); // 清零栈

        // 设置线程的上下文
        if (setjmp(new_context.jmp_buf) == 0) {
            // 初始化栈指针
            stack_top -= 8; // 调整栈指针,模拟函数调用
            uintptr_t* rsp = (uintptr_t*)stack_top;
            *rsp = (uintptr_t)threadWrapper; // 设置返回地址为threadWrapper

            // 设置寄存器
            new_context.jmp_buf[0].__jmpbuf[4] = (uintptr_t)stack_top; // RSP
            new_context.jmp_buf[0].__jmpbuf[5] = (uintptr_t)&task;   // R13, 这里传函数指针
        }

        threads.push_back(new_context);
        return threads.size() - 1; // 返回线程ID
    }

    // 调度线程
    void yield() {
        int current_id = current_thread_id;
        int next_id = (current_id + 1) % threads.size();

        // 保存当前线程的上下文
        if (setjmp(threads[current_id].jmp_buf) == 0) {
            // 切换到下一个线程
            current_thread_id = next_id;
            longjmp(threads[next_id].jmp_buf, 1);
        }
    }

private:
    vector<ThreadContext> threads; // 线程列表
    int current_thread_id; // 当前运行的线程ID

    // 线程包装函数
    static void threadWrapper(function<void()>* task) {
        (*task)(); // 执行线程的任务

        // 线程结束
        int current_id = getCurrentThreadId();
        threads[current_id].state = FINISHED;
        free(threads[current_id].stack);

        // 调度到下一个线程
        yield();
    }

    // 获取当前线程ID
    static int getCurrentThreadId() {
        return instance->current_thread_id;
    }

public:
    static UserThreadLibrary* instance;

    // 初始化静态成员变量
    static void init() {
        instance = new UserThreadLibrary();
    }
};

UserThreadLibrary* UserThreadLibrary::instance = nullptr;

int main() {
    // 初始化用户线程库
    UserThreadLibrary::init();
    UserThreadLibrary* utl = UserThreadLibrary::instance;

    // 创建线程
    int thread1_id = utl->createThread([]() {
        for (int i = 0; i < 5; ++i) {
            cout << "Thread 1: " << i << endl;
            utl->yield(); // 主动放弃CPU
        }
    });

    int thread2_id = utl->createThread([]() {
        for (int i = 0; i < 5; ++i) {
            cout << "Thread 2: " << i << endl;
            utl->yield(); // 主动放弃CPU
        }
    });

    // 运行主线程
    for (int i = 0; i < 5; ++i) {
        cout << "Main Thread: " << i << endl;
        utl->yield(); // 主动放弃CPU
    }

    return 0;
}

代码解释:

  • ThreadContext 结构体: 存储线程的上下文信息,包括栈、状态、任务等。
  • UserThreadLibrary 类: 模拟一个用户态线程库。
    • createThread(): 创建线程,分配栈空间,设置线程上下文。
    • yield(): 线程主动放弃CPU,切换到下一个线程。使用setjmplongjmp实现上下文切换。
    • threadWrapper(): 线程的包装函数,负责执行线程的任务,并在线程结束后释放资源。
  • main() 函数: 创建线程并运行。

重点:setjmplongjmp

这两个函数是实现用户态线程上下文切换的关键。

  • setjmp(jmp_buf): 保存当前线程的上下文到 jmp_buf 中,返回0。
  • longjmp(jmp_buf, value): 从 jmp_buf 中恢复上下文,程序从 setjmp 的地方继续执行,但 setjmp 返回 value

用户态线程的优势与劣势:

  • 优势: 切换速度快,资源占用少,可以灵活定制调度策略。
  • 劣势: 依赖于进程,一个线程阻塞会导致整个进程阻塞。而且,由于内核不知道用户态线程的存在,无法利用多核CPU的优势。

第四幕:M:N 线程模型——“左右逢源”

为了结合内核态线程和用户态线程的优点,出现了M:N线程模型。

  • M:N 模型: 将M个用户态线程映射到N个内核态线程上。用户态线程的调度由用户程序自己控制,内核态线程的调度由内核控制。这样既可以利用多核CPU的优势,又可以减少上下文切换的开销。

举个例子: 假设有8个用户态线程,映射到4个内核态线程上。这4个内核态线程在4个CPU核心上并行运行,每个核心上可以运行2个用户态线程。

第五幕:C++11 与线程

C++11 引入了标准线程库 <thread>,它底层通常使用内核态线程(例如 Linux 下的 pthread)。虽然我们也可以自己实现用户态线程库,但在实际开发中,很少会这样做,因为标准线程库已经足够强大和方便了。

总结:

线程调度是一门复杂的学问,涉及到操作系统的底层机制。理解用户态线程和内核态线程的区别,以及各种调度策略,可以帮助我们更好地编写并发程序,提高程序的性能。

记住,选择哪种线程模型,取决于具体的应用场景。如果对性能要求非常高,可以考虑M:N模型或者自己实现用户态线程库。但大多数情况下,使用C++11的标准线程库就足够了。

这场线程的“宫斗戏”就先讲到这里,希望大家有所收获!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注