庖丁解牛:从“任务”到“效率”–深入理解进程、线程与协程(C++)

你好!作为一名C++程序员,你一定听说过”进程“、”线程“、”C++并发编程“这些术语。它们是构建现代复杂应用程序的基石,也是优化程序性能、提高资源利用率的关键。然而,对于许多初学者来说,这些概念可能会显得有些抽象和难以捉摸。

今天,我们就来一次深度刨析,从最基础的概念开始,一步步深入到C++中多任务编程实践,最终触及当下流行的协程技术。准备好了吗?让我们开始吧!

第一站:理解程序、进程–”静“与”动“的艺术

在深入探讨线程和协程之前,我们首先需要明确”程序“和”进程“这两个概念。

程序:你可以把程序想象成一个静态的、存储在磁盘上的指令集合。比如你用C++写好并编译成a.out(Linux)或example.exe(Windows),这就是一个程序。它就像是一张乐谱,本身不会发出声音,只是一系列指令的排列。

进程:当你的程序被加载到内存中,并由操作系统(os)开始执行时,它就变成了一个动态的、正在运行的实例。这就是一个”活“的实体,拥有自己独立的内存空间、文件句柄、系统资源等。每个进程都像一个独立的”运行“环境或”工作坊“,它有自己的”工具箱“和”工作台“,可以独立完成任务,互不干扰。C++视角下:

当你编译并运行一个C++程序时,例如:

#include <iostream>
#include <unistd.h> // for getpid() on Linux/macos
​
int main(){
    std::cout << "Hello from my program!" << std::endl;
    std::cout << "My process ID is: " << getpid() << std::endl; //打印进程ID
    //...程序的后续逻辑
    return 0;
}

当你执行./a.out时,操作系统会创建一个新的进程来执行你的main函数。这个进程拥有自己的虚拟地址空间,里面的代码、数据、堆、栈都是独立的。进程的特点:

  • 独立性:每个进程都有自己独立的内存空间,一个进程的崩溃通常不会影响其他的进程。
  • 资源拥有者:进程是系统资源分配的基本单位,它拥有程序代码、数据、文件、设备等资源。
  • 开销大: 创建和销毁进程的开销相对较大,进程之间的通信(IPC)需要特定的机制(如管道、共享内存、消息队列等),且相对复杂。

第二站:理解线程–进程内的”分身术“

一个进程往往需要完成多个任务。如果所有任务都串行执行,那么效率会很低,尤其当某个任务需要等待I/O(读取文件、网络通信)时,整个进程就会阻塞。为了解决这个问题,引入了”线程“的概念。

线程:线程时进程内的一个执行单元,是CPU调度的基本单位。一个进程可以包含一个或多个线程。你可以把线程想象成进程内部的”工人“。一个工作坊(进程)里可以有一个工人(单线程),也可以有多个工人(多线程),这些工人都共享工作坊里的工具和设施(进程的内存空间和资源)。线程的特点(与进程对比):

  • 共享资源:同一个进程内的所有线程共享进程的内存空间、文件描述等资源。这意味着它们可以直接访问进程的数据,通讯效率高。
  • 独立执行流:每个线程都有自己独立的程序计数器、栈和寄存器,确保它们之间可以独立执行不同的代码路径。
  • 轻量级:创建和销毁线程的开销比进程小的多。线程之间的切换(上下文切换)也比进程之间切换更快。
  • 并发性:多线程可以实现并行或并发执行。在多核CPU上,多个线程可以真正地并行运行;在单核CPU上,操作系统通过时间片轮转,使得多个线程并发执行,看起来像是在同时运行。

C++视角下(std::thread):

C++11引入了标准库<thread>,使得多线程编程变得非常便捷。

#include <iostream>
#include <thread> //引入线程库
#include <vector>
#include <chrono> // for std::chrono::seconds
​
//线程要执行的函数
void task_function(int id){
    std::cout << "Thread " << id << " started." << std::endl;
    //模拟耗时操作
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Thread " << id << " finished." << std::endl;
}
​
int main(){
    std::cout << "Main thread started." << std::endl;
    
    //创建并启动两个线程
    std::thread t1(task_function,1); //线程1执行task_function(1)
    std::thread t2(task_function,2); //线程2执行task_function(2)
    
    //主线程可以继续做自己的是事情
    std::cout << "Main thread is doing something else..." << std::endl;
    
    //等待子线程完成(join)
    //join会阻塞主线程,直到对应的子线程执行完毕
    t1.join();
    t2.join();
    
    std::cout << "All threads finished. Main thread existing." << std::endl;
    return 0;
}

编译运行:

g++ -std=C++11 -pthread example.cpp -o example

(在Linux/MacOS上需要链接pthread库,-pthread会自动处理),运行结果如下:

Main thread started.
Thread 1 started.
Thread 2 started.
Main thread is doing something else...
Thread 1 finished.
Thread 2 finished.
All threads finished. Main thread exiting.

可以看到,Thread 1 started. 和Thread 2 started.是几乎同时打印的,这表明两个线程在并发执行。Main thread is doing something else…也在它们执行的同时进行。线程同步与互斥

由于线程共享内存,这带来了便利,同时也带来了数据竞争的问题。当多个线程同时读写同一块共享数据时,如果没有适当的同步机制,程序的行为将是不可预测的。这就是需要使用互斥量(std::mutex)、条件变量(std::condition_variable)、读写锁(std::shared_mutex)、原子操作(std::atomic)等同步机制的原因。示例:使用std::mutex保护共享数据

#include <iostream>
#include <thread>
#include <vector>
#include <mutex> //引入互斥量
#include <numeric> //for std::accumulate
​
long long global_sum = 0;
std::mutex mtx; //定义一个互斥量
​
void add_to_sum(int start, int end){
    for(int i = start; i <= end; ++i){
        //使用lock_guard,它在构造时加锁,析构时自动解锁
        std::lock_guard<std::mutex> guard(mtx);
        global_sum += i;
    } 
}
​
int main(){
    std::cout << "Calculing sum with threads..." << std::endl;
    
    const int num_threads = 4;
    const int total_numbers = 100000;
    const int numbers_per_thread = total_numbers / num_threads;
    
    std::vector<std::thread> threads;
    for(int i= 0; i < num_threads; ++i){
        int start = i * numbers_per_thread + 1;
        int end = (i == num_threads - 1) ? total_numbers : (i + 1) * numbers_per_thread;
        threads.emplace_back(add_to_sum,start,end);
    }
    
    for(std::thread& t : threads){
        t.join();
    }
    
    long long expected_sum = (long long)total_numbers * (total_numbers + 1) / 2;
    
    std::cout << "Expected sum: " << expected_sum << std::endl;
    std::cout << "Calculated sum: " << global_sum << std::endl;
    
    if(global_sum == expected_sum){
        std::cout << "Sums match! Data was protected correctly." << std::endl;
    }else{
        std::cout << "Sums mismatch! Something went wrong with synchronization." << std::endl;
    }
    
    return 0;
}

在这个例子中,每一次对global_sum的修改都被std::lock_guardstd::mutex guard(mtx);所保护。这确保了在任何给定时间,只有一个线程能够访问并修改global_sum,从而避免了数据竞争,保证了计算的正确性。

第三站:理解协程–更轻量的“协作式”并发

进程和线程解决了并发执行的问题,但它们依然有其局限性:

  • 线程切换开销:尽管比进程小,但线程上下文切换仍然需要保存和恢复寄存器、TLB等信息,并可能导致缓存失效,这在大量线程切换时会带来性能消耗。
  • 资源消耗:每个线程都需要一定的栈空间和内核资源。
  • 复杂性:多线程编程中,线程同步(锁、信号量等)的引入使得程序逻辑变得复杂,容易出错(如死锁、活锁、饥饿)。

为了进一步提高并发效率和简化编程模型,协程应运而生。

协程:协程是一种用户态的、非抢占式(协作式)的并发模型。它允许函数(或任务)在执行过程中暂停,然后将控制权主动交还给调用者或调度器,并在需要时从暂停点恢复执行。

  • 用户态:协程的切换完全发生在用户空间,不需要操作系统的参与,因此没有内核态/用户态切换的开销。
  • 非抢占式/协作式:核心区别在于,线程由操作系统调度器进行强行切换,而协程必须主动放弃CPU控制权。如果一个协程不主动暂停(yield),它会一直运行直到完成,可能阻塞其他协程。这就像一个团队,成员们约定好轮流发言,而不是由领导强制打断。
  • 更轻量:协程通常比线程拥有更小的栈空间,创建和切换开销极小。

协程的优势:

  • 极高的并发性:可以轻松创建成千上万个协程。
  • 更低的开销:切换成本极低。
  • 简化异步编程:通过同步的写法实现异步逻辑,避免回调地狱,可读性更强。

C++视角下(C++20):

C++20正式引入了协程支持,通过新的关键字co_await、co_yield、co_return来辅助实现。这是一种基于编译器转换的机制,编译器将协程函数转换为状态机,使得函数可以在某个点暂停并稍后恢复。理解C++20协程需要一定的学习曲线,因为它涉及到了一些底层概念(如承诺类型 promise_type、挂起点suspend_always、suspend_never等)。这里我们提供一个简化的C++20协程示例,来展示其暂停/恢复的核心思想。

示例:C++20协程模拟简单的序列生成器

假设我们想创建一个函数,每次调用它时,它返回序列中的下一个数字,但这个函数可以在内部暂停,而不必每次都从开头开始。这正是co_yield的典型应用场景。

#include <vector>
#include <coroutine> //C++20 协程头文件
#include <optional>
#include <vector>
​
//1.协程的承诺类型(promise_type)
//决定协程如何暂停、恢复、返回值等
template <typename T>
struct Generator{
    struct promise_type{
        T value_; //存储co_yield的值
        std::coroutine_hand<promise_type> parent_handle; //存储调用者的句柄
        
        //当协程首次进入时,在这里生成协程句柄,并将其返回给调用者
        Generator get_return_object(){
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        
        //协程开始执行时挂起(通常设置为 suspend_always才能暂停)
        std::suspend_always initial_suspend(){return {};}
        
        //协程结束时挂起(可以用来清理资源,或者实现final_suspend机制)
        std::suspend_always final_suspend() noexcept {return {};}
        
        //处理co_yield T; 语句,将值存储起来,并挂起协程
        std::suspend_always yield_value(T value){
            value_ = value;
            return {}; //挂起协程
        }
        
        //处理co_return;或co_return something;语句
        void return_void(){} //我们的生成器不直接返回,通过co_yield返回
        
        //异常处理
        void unhandled_exception(){
            //通常这里会捕获异常,或者重新抛出
            std::rethrow_exception(std::current_exception());
        }
    };
    
    //协程句柄,用于控制协程的生命周期和执行
    std::coroutine_handle<promise_type> handle_;
    
    //构造函数
    explicit Generator(std::coroutine_handle<promise_type> h) : handle_(h) {}
    
    //析构函数,确保协程句柄被销毁
    ~Generator(){
        if(handle_){
            handle_.destroy();
        }
    }
    
    //移动语义(禁止拷贝,协程句柄时独占资源)
    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;
    Generator(Generator&& other) noexcept : handle_(other.handle_){
        other.handle_ = nullptr;
    }
    Generator& operator=(Generator&& other) noexcept {
        if (this != &other){
            if (handle_) handle_.destroy();
            handle_ = other.handle_;
            other.handle_ = nullptr;
        }
        return *this;
    }
    
    //检查协程是否已经完成
    bool done() const {return !handle_ || handle_.done();}
    
    //恢复协程执行,并返回co_yield的值
    std::optional<T> next(){
        if (!handle_ || handle_.done()){
            return std::nullopt; //协程已完成或未初始化
        }
        // 恢复协程执行,直到遇到co_yield 或 co_return
        handle_.resume();
        
        if(handle_.done()){
            return std::nullopt; //协程执行完毕
        }
        return handle_.promise().value_; //返回co_yield的值
    }
};
​
//2.一个使用 co_yield的协程函数
Generator<int> fibonacci_generator(int limit){
    int a = 0 , b =1;
    for(int i = 0; i < limit; ++i){
        co_yield a; //暂停,并返回a
        int next_fib = a + b;
        a = b;
        b = next_fib;
        if(a > 1000){ //模拟一些额外条件下的退出
            std::cout << "Fibonacci: Reached limit 1000, stop generating." << std::endl;
            co_return; //结束协程
        }
    }
    std::cout << "Fibonacci: All specified numbers generated." << std::endl;
}
​
int main(){
    std::cout << "Generating Fibonacci sequence:" << std::endl;
    Generator<int> fib_gen = fibonacci_generator(10); //创建协程对象
    
    while(auto value = fib_gen.next()){ //每次调用next()恢复协程,直到co_yield
        std::cout << *value << " ";
    }
    std::cout << "\nLong sequence generation finished." << std::endl;
    
    return 0;
}

编译运行:

g++ -std=c++20 -fcoroutines example.cpp - o example

你需要支持C++20和fcoroutines的G++或Clang版本,输出大致如下:

Generating Fibonacci sequence:
0 1 1 2 3 5 8 13 21 34 
Fibonacci: All specified numbers generated.
Sequence generation finished.
​
Generating another sequence with a higher limit:
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 Fibonacci: Reached limit 1000, stop generating.
Long sequence generation finished.

在这里例子中,fibonacci_generator函数看起来像是一个普通的函数,但它使用了co_yield。每此fib_gen.next()被调用时,协程从上次co_yield的地方恢复执行,直到遇到下一个co_yield或者co_return。这使得我们可以在需要时“拉取”下一个值,而不是一次性生成所有值。

协程的使用场景:

  • 异步I/O操作:网络请求、文件读写等,当等待I/O时可以co_await,将控制权和交还给其他协程。
  • 延迟计算/惰性求值:如上述的生成器。
  • 状态机:通过co_yield和co_await可以清晰地表达状态转换。
  • 并发任务流:在大量业务逻辑需要异步协同的场景。

总结表格:进程、线程、协程对比

特性/维度进程线程协程
定义操作系统资源分配的基本单位CPU调度和执行的基本单位,是进程内的执行流用户态的、协作式调度(非抢占式)的轻量级执行单元
资源拥有独立内存空间、文件、设备等所有系统资源共享进程的内存空间和大部分系统资源,有独立的栈和寄存器共享线程的内存空间,但有独立的栈(或少量堆空间)
切换上下文切换设计OS,开销最大,需要内核态/用户态切换上下文切换涉及OS,开销较小,仍可能涉及内核态/用户态切换用户态切换,无需OS参与,开销极小
调度OS调度器抢占式调度OS调度器抢占式调度用户程序或协程调度器协作式调度(必须主动交出控制权)
通信IPC(管道、共享内存、消息队列等),复杂且高开销直接访问共享内存,通过同步机制(锁、条件变量等)同步直接访问共享内存,通常不需要锁(因为是协作式,一个协程运行时另一个不会跑)
并发数数十到数百个数百到数千个数万到数十万个(理论上更多)
隔离性强(一个进程崩溃不影响其他)弱(一个线程崩溃可能导致整个进程崩溃)极弱(一个协程的错误可能影响到同线程内的其他协程)
C++支持frok/exec(系统调用),<process>等(非标准)<thread>(C++11+)<coroutine>(C++20+)
典型应用独立的应用程序(浏览器、IDE等)UI响应、后台任务、并发计算、服务器请求处理高并发网络服务、异步I/O、生成器、NPM包管理器命令等

总结

通过这篇博客,我们由浅入深地探讨了进程、线程和协程这三个重要的并发概念。

  • 进程是程序执行的独立实例,拥有独立的资源。
  • 线程是进程内的执行单元,共享进程资源,通过操作系统实现抢占式并发。
  • 协程是用户态的、协作式的并发单元,通过主动挂起/恢复实现超轻量级并发。

在C++中,std::thread使得多线程编程成为标准,但需要注意数据同步的复杂性。C++20协程的引入,则为我们提供了更强大、更优雅的异步编程和高并发解决方案。

选择哪种并发模型,取决于你的应用场景和性能需求。单核CPU上,多线程/多进程是并发,而多核CPU上,它们是并行。协程则在单线程内实现多个任务的协作,在某些场景下可以发挥极致的性能和简洁性。

希望这篇博客能帮助你更好地理解这些概念,并在未来的C++编程中,能够游刃有余地驾驭它们,写出高效、健壮的并发程序!如果你有任何疑问,欢迎留言交流。 ​

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇