本文所有的内容基于linux平台,同时也是基于作者自身的理解,有不当之处,还请指出,同时 仅供参考

在解释这三个的概念之前,让我们先有个 不太正确,但又有一定道理的认识吧:

  • 线程,人称 轻量级进程
  • 协程,人称 轻量级线程

到这里,其实你就能够推断出他们三者之间的 量级 关系了:进程 > 线程 > 协程

那自然而然,你就能够继续推出,X的创建成本和X之间的切换成本 应该是与 X的量级 成正相关的,即 量级 越大,成本 越高

事实上,正常情况下也确实是如此,恭喜你,你已经初步了解了他们之间的区别了

在继续了解之前,你需要了解 用户态和内核态 内核态与用户态

线程

线程,是程序执行的最小单位,我们将程序 自然执行 的顺序称为 执行流,那么,一个 线程,其实就是一个 执行流

一般情况 下,我们的程序只有一个 执行流

以C语言为例,就是,我们从 main 函数进入,然后执行 main函数内的内容,最后从 main 函数返回,程序就结束了

#include <stdio.h>

int main(void)
{
    printf("hello world\n");
    return 0;
}

因为程序都是从 main 函数启动,所以这个 执行流 又被称为 主线程

那有 主线程,是不是还有其他 从线程?有没有 从线程 我不知道,不过确实是可以有其他 线程

// Usage: gcc -std=c11 -o main main.c -pthread && ./main
#include <stdio.h>
#include <assert.h>
#include <threads.h>

int work(void *arg)
{
    (void) arg; // ignore
    printf("hello, this is new thread\n");
    return 0;
}

int main(void)
{
    printf("hello, this is main thread\n");

    thrd_t thread;

    // create a thread
    assert(thrd_create(&thread, work, NULL) == thrd_success);
    // main thread wait the new thread
    assert(thrd_join(thread, NULL) == thrd_success);

    return 0;
}

在这个例子中,我们新创建了一个 线程,其实它的效果和你调用一个普通函数没有差别,但他们不一样的地方在于:

  • 在main函数中调用普通函数,一个执行流,main函数只有等调用函数结束后才能继续执行
  • 在main函数中创建另一个线程并执行,两个执行流,main函数在创建并启动完新线程后,可以继续执行,同时新线程也能同时执行

是不是察觉到区别了,没错,多个执行流 就能够实现 并发 甚至 并行 的效果(并发是通过快速的切换模拟同时进行,并行是真正的同时进行

好了,到这里,相信你已经能够理解 线程和执行流 的关系了

进程

进程,是资源分配的最小单位

为什么我们要先讲 线程 呢?原因是,进程其实就包括了主线程以及其他资源执行流 依赖的是 CPU,而这也是一种资源,所以我希望大家能够对 执行流主线程 有一定的了解,才能理解 进程是资源分配的基本单位,执行也是从进程开始的

进程要讲起来的话需要非常大的篇幅,其中就是关于它所拥有的资源的介绍,我们不打算介绍,简单将其理解为变量和内存即可(和我们的例子相关)

函数的执行信息(包括调用者,被调用者,以及他们的局部变量等)会被存储在栈上,以一种逻辑结构 栈帧 的形式存储,前文我们提到,我们除了 主线程,还可以创建其他的 线程,这里我们将其规范一下,进程可以拥有多个线程

想想看,每个 线程 都是一个 执行流,那必然都需要有自己的执行环境,所以我们就将 进程内存资源 划分一部分作为 每个线程的私有栈,这样他们的私有信息就不会交杂在一起,造成污染了,同样的,既然 这些线程都属于一个进程,那么就意味着他们能够同时访问这个进程中的某一部分资源,这部分资源就称作 公共资源

前文提到过,多个执行流,即多线程 可以实现 并行 的效果,也就是同时进行,所以在 多线程 环境下,我们有多个 同时进行执行流,但是 公共资源 只有一份,这就意味着会产生 竞争,此时对数据的保护就尤为重要了

到这里,我相信你对 进程和资源 以及 多线程 有了更深的了解,那你是否想过 多进程 呢?

多进程 就意味着,有多份 独立 的资源,因为每个进程内的 执行流 只能在自己的环境中执行,不会出现 跨进程 的情况,这主要得益于 虚拟内存 的机制,感兴趣的同学可以进一步查找资料

我差点忘了,我们还没有举例子,现在来几个例子吧

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int data = 0; // 公共变量

int main(void)
{
    pid_t pid = fork(); // 从此刻起,我们有了两个进程,他们是相同的代码,并进入不同的分支执行
    if (pid == 0) {
        // child process
        data = 1;
        printf("child process: data = %d\n", data);
        exit(0);
    }
    // parent process
    wait(NULL);
    printf("parent process: data = %d\n", data);

    return 0;
}

1

在上述的例子中,我们验证了,每个进程都有自己的资源(即在父子进程中都改变公共变量,并不会影响对方)

接下来在看看多线程的情况吧

#include <stdio.h>
#include <assert.h>
#include <threads.h>

int data = 0;

int work(void *arg)
{
    (void) arg;
    data += 1;
    printf("new thread: data = %d\n", data);
    return 0;
}

int main(void)
{
    thrd_t thread1, thread2;
    
    // create two new threads
    assert(thrd_create(&thread1, work, NULL) == thrd_success);
    assert(thrd_create(&thread2, work, NULL) == thrd_success);

    // main thread wait unitl other threads finish
    assert(thrd_join(thread1, NULL) == thrd_success);
    assert(thrd_join(thread2, NULL) == thrd_success);

    printf("main thread: data = %d\n", data);

    return 0;
}

2

可以看到在这个例子中,线程 对公共变量的修改反映在了 其他线程 上,这就说明这个 公共变量 不是线程私有的

协程

协程,一种特殊的控制流,我们这里引入了一个新的概念 --- 控制流,还记得我们之间的 执行流 吗,不知道你有没有注意到 自然执行,其实简单的 自上而下,而 控制流 就是改变 执行流 的执行顺序,比如常用的 if/while/for

这里我们说 协程是一种特殊的控制流,这只是我的理解方式,因为本质上,协程 是在一个 执行流 中改变执行的顺序,所以应该是一种 控制流,但是它特殊的地方在于,他将这种 改变的方法 封装成了一个 伪执行流,而不是传统的 条件判断,这么说还是有点抽象了,让我们举个例子吧

这是我自己实现的一个简单的协程

#include "coroutine.h"

void *counter(void *arg)
{
    (void) arg;
    int n = 4;
    for (int i = 0; i < n; i++) {
        printf("[%zu] %d\n", coroutines.cur_co_id, i);
        // 停止执行,返回到main函数中
        coroutine_yield(NULL);
    }

    coroutine_finish();
    return NULL;
}

int main(void)
{
    coroutine_init();
    // 创建协程,每个协程的启动函数是 'counter'
    struct Coroutine *co1 = coroutine_create(counter);
    struct Coroutine *co2 = coroutine_create(counter);
    struct Coroutine *co3 = coroutine_create(counter);

    int dead = 0;
    while (1) {
        // 切换不同的协程执行,如果状态都是死亡,就跳出循环
        coroutine_resume(co1); dead += (co1->status == DEAD) ? 1 : 0;
        coroutine_resume(co2); dead += (co2->status == DEAD) ? 1 : 0;
        coroutine_resume(co3); dead += (co3->status == DEAD) ? 1 : 0;

        if (dead == 3) break;
    }

    printf("well done\n");
    coroutines_destroy();

    return 0;
}

如果你之前看懂了 线程 的例子的话,那么这里的例子应该没什么问题,你可以对比一下,你会发现,两者的区别在于

-- 创建线程之后,该干嘛干嘛,线程会 自动 执行,

-- 创建协程之后,你需要 手动 的进行切换,停止等等

我们看看效果,可以看到,输出的效果是不是有点 并发 的影子,但我们只使用了一个 执行流,我们通过对执行环境的切换,从而进入不同的 协程 中,这其实和 线程 很像,但它只有一个 执行流,所以看起来就类似 全局goto 的感觉,只不过它将其封装成了 协程,更加的优雅,容易管理

2

对比

如果你能看到这里,我想,你应该是 一头雾水 的,:-),这不是你们不行,而是因为我在写的时候,总感觉有很多可以说,但是又不能全部写下来,所以总感觉比较杂乱,你可以试着说说 进程、线程、协程 之间的联系和区别,可能只能记住每一个小节开头的那句话,这是我的问题

然后在这里,我们总结总结他们的区别和联系

需要注意的是,本文聚焦的是 一对一 的线程模型以及 有栈 协程

linux中的线程 这篇文章的末尾介绍了下线程实现模型

进程和线程

  • 扮演的角色

    • 进程是资源分配的最小单位
    • 线程是程序执行的基本单位
  • 独立性对比

    • 进程之间由于虚拟内存机制的原因,独立性更强
    • 线程之间由于共享进程的一部分资源,关联性更强
  • 安全性对比

    • 进程之间的独立性更强,某个进程的问题不容易扩散到其他进程,安全性更强
    • 线程之间的关联性更强,由于资源共享的原因,对于共享资源的访问会造成并发冲突,且非常容易扩散,安全性较低
  • 切换成本

    • 进程的量级更大,上下文切换包括其所拥有的资源以及执行环境
    • 线程的量级更小,上下文切换只需要切换自己私有的部分和执行环境,公共部分由进程保留

线程和协程

  • 扮演的角色

    • 线程是程序执行的基本单位
    • 协程是一种特殊的控制流
  • 切换方式

    • 线程之间的切换是抢占式的,由系统进行调度
    • 协程之间的切换是协作式的,手动进行调度
  • 切换成本

    • 线程的切换是由系统进行调度的,即需要陷入内核态,在进行上下文切换,成本更高
    • 协程的切换是手动调度的,不需要陷入内核态,全部在用户态发生,成本更低
  • 安全性对比
    协程是在单个 执行流 中使用,并不会产生 并发 问题,因此更加安全

总结

事实上他们的联系和区别还有挺多方面的,比如在不同的线程实现模型下,他们的表现可能又不同,具体情况具体分析,这里的对比只能给出几个方面来分析他们之间的区别和联系,并不是说只有这些

再次强调,仅供参考,有错误的话,还请指出

 posted on 2025-05-06 16:52  Dylaris  阅读(74)  评论(0)    收藏  举报