醋醋百科网

Good Luck To You!

【不背八股】2.操作系统-进程、线程、协程的基本理解

1. 引言

在计算机的发展早期,CPU 一次只能干一件事,比如你开个文档,就只能编辑文档,不能同时听音乐。后来,随着硬件性能的提升和人类对效率的追求,我们希望计算机能“同时”做多件事——这就引出了并发(Concurrent) 和 并行(Parallel) 的概念。

为了更好地实现并发,操作系统和编程语言为我们提供了不同层次的工具:进程、线程、协程。

本文来梳理操作系统中,进程、线程、协程这三个概念及相关内容。

2. 进程(Process)

基本定义:

进程是操作系统资源分配和调度的最基本单位。

每个进程拥有独立的地址空间(代码段、数据段、堆、栈)、文件描述符、全局变量等。

内核通过进程控制块(PCB, Process Control Block)来管理进程的状态与上下文。

优缺点:

  • 优点:安全隔离性强,一个进程崩溃不影响其他进程。
  • 缺点:创建销毁和切换开销大,通信成本高。

3. 线程(Thread)

基本定义:

线程是进程内的执行单元,属于调度的最小单位。

一个进程可包含多个线程,线程共享进程的资源(代码段、数据段、堆),但每个线程拥有独立的栈和寄存器。

线程控制块(TCB, Thread Control Block)存储线程上下文。

优缺点:

  • 优点:通信简单,创建/销毁/切换成本较低。
  • 缺点:线程安全问题复杂,可能导致死锁、竞争条件;一个线程崩溃可能导致整个进程崩溃。

4. 协程(Coroutine)

基本定义:

协程是一种用户态的轻量级线程,由程序控制调度。

相比线程,协程切换不依赖操作系统,而是由语言运行时或库实现。

协程支持挂起与恢复,常见形式为 async/await。

优缺点:

  • 优点:极轻量,结构化异步代码可读性好。
  • 缺点:本质仍在单线程上运行,计算密集型任务不适合;若协程中调用阻塞 API,会阻塞整个线程。

5. 进程调度算法

进程调度是操作系统内核在多任务环境中,决定下一个使用 CPU 的进程是谁的机制。

进程调度算法大体可分为抢占式调度非抢占式调度

抢占式调度是指进程正在运行的时,可以被打断,使其把 CPU 让给其他进程。

非抢占式调度是指进程正在运行时,不会被打断,只有当进程运行结束或阻塞,才会把 CPU 让给其他进程。

举个例子,现在在写这篇文章时,突然收到了领导的电话,不得不把我的大脑(cpu)让给领导的任务,这就是个人的抢占式调度。

进一步细分,进程调度算法包含下算法:

(1) 先来先服务(FCFS, First Come First Serve)

思想:非抢占式,先到先得,很基本的思路。

(2) 最短作业优先(SJF, Shortest Job First)

思想:非抢占式,选择预计运行时间最短的作业/进程优先执行,例如在蜜雪冰城,点甜筒的顾客可以优先得到响应处理。

(3) 最短剩余时间优先(SRTF, Shortest Remaining Time First)

思想:抢占式,每当新进程到来时,如果其预计运行时间比当前进程剩余时间更短,则抢占 CPU。例如,某雪冰城的服务员看到新来的顾客点了甜筒,做甜筒的时间短于做剩余一半奶茶的时间,那就优先处理新顾客的需求。

用奶茶店的例子去类比理解,通俗易懂

(4) 时间片轮转(RR, Round Robin)

思想:为每个进程分配固定长度的时间片(如 10ms),用完即切换。相对公平的方案,比如,两个奶茶店顾客,先来的点了两杯奶茶,后来的点了一杯奶茶,设置时间片长度为做一杯奶茶的时间,那就会先给先到的顾客做一杯,然后再给第二个顾客做一杯。

(5) 优先级调度(Priority Scheduling)

思想:根据优先级选择进程运行,类似于奶茶店开了个VIP用户渠道,VIP用户来得晚也可以插队被服务。

(6) 多级反馈队列(MLFQ, Multi-Level Feedback Queue)

思想:结合优先级 + 时间片两种算法的思想,进程初始进入高优先级队列,若未完成则逐渐降低优先级。I/O 密集型进程(短任务)容易在高优先级队列快速完成。

那么,Linux 系统采用的是什么进程调度方案?

作为现代操作系统,Linux 面临的环境要复杂得多:

  • 既要保证交互进程的响应速度(比如你敲键盘、点击鼠标)
  • 又要兼顾后台批处理或长时间运行的服务进程
  • 同时还要在多核 CPU 上实现负载均衡
  • 并且对实时进程(RT)和普通进程(CFS 管理)做区分。

所以 Linux 从 2.6.23 起引入了 CFS(Completely Fair Scheduler,完全公平调度器)

CFS 借鉴了时间片轮转、最短作业优先、优先级调度等各种算法思想,使每个任务有一个虚拟运行时间(vruntime),运行得越久,vruntime 越大;运行得越少,vruntime 越小,调度器总是选择 vruntime 最小的任务来运行。

6. 进程通信方式

进程是操作系统进行资源分配和调度的基本单位。由于进程之间相互独立,每个进程拥有自己的虚拟地址空间,不能直接访问彼此的数据。为了实现进程之间的协作或数据交换,就需要进程间通信(Inter-Process Communication,IPC) 机制。

常见的进程通信机制有以下几种:

(1) 管道(Pipe)

半双工(数据只能单向流动)的通信模式,通过内核缓冲区实现,一个进程写入,另一个进程读取。

例如,使用命令行罗列出当前文件夹下所有.txt的文件。

ls | grep ".txt"

ls 的输出通过管道传给 grep 进程。

(2) 消息队列(Message Queue)

消息队列提升了管道通信的通信效率,其支持随机读写,而不是像管道一样顺序读写,适合需要频繁通信的进程之间。

缺陷是消息队列在内核中有大小限制,可能导致消息丢失或阻塞。

(3) 共享内存(Shared Memory)

多个进程可以映射同一块物理内存区域到自己的虚拟地址空间,直接读写数据,通信速度最快,因为避免了数据在内核与用户空间的多次拷贝。

缺陷是需要额外的同步机制(信号量、互斥锁),避免数据竞争。

(4) 信号量(Semaphore)

信号量是配合共享内存的一种辅助方式。多个进程通过共享内存,可能会造成冲突的问题。

信号量表示资源的数量,在进程访问共享内存前,信号量执行P操作,类似于申请资源;结束后,执行V操作,类似于释放资源。

通过该机制,实现多进程安全地读写共享区域。

(5) 信号(Signal)

信号是一种异步通知机制,用于告知进程某个事件发生。

在linux系统中,输入kill -l可以看到系统自带的各种信号。

通常在杀死某进程时,会使用

kill -9 进程号

这个命令本质上就是对进程号对应的进程,发送SIGKILL信号,表示终止该进程。

(6) 套接字(Socket)

Socket是一种通用的通信方式,既支持同一台机器上的进程通信,也支持跨网络的进程通信,但开销比共享内存大。

7. 线程冲突问题

在多线程编程中,多个线程会并发执行。当它们访问共享资源(如内存变量、文件、数据库连接等)时,如果缺乏适当的同步机制,就可能导致线程冲突问题。

为了防止不同线程在共享内存中编辑时的冲突问题,一种方式就是在某线程访问时,在共享数据访问处加锁,避免数据不一致。

在 python 除3.14+freethreaded版本之外,由于存在GIL锁,导致多线程无法实现真正的并发,反而不存在线程冲突问题。

而 C++ 在使用多线程时,如果不加互斥锁,会出现问题,下面是一个例子:

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>

int counter = 0; // 全局共享变量
std::mutex mtx;  // 互斥锁

// 不加锁的情况
void worker_without_lock(int n)
{
    for (int i = 0; i < n; i++)
    {
        counter++; // 这里可能会发生数据竞争
    }
}

// 加锁的情况
void worker_with_lock(int n)
{
    for (int i = 0; i < n; i++)
    {
        std::lock_guard<std::mutex> lock(mtx);
        counter++; // 在锁的保护下访问共享变量
    }
}

int main()
{
    int iterations = 100000;
    int num_threads = 4;

    // ----------- 不加锁 -----------
    counter = 0;
    {
        std::vector<std::thread> threads;
        for (int i = 0; i < num_threads; i++)
        {
            threads.emplace_back(worker_without_lock, iterations);
        }
        for (auto &t : threads)
            t.join();
    }
    std::cout << "不加锁结果: " << counter << std::endl;

    // ----------- 加锁 -----------
    counter = 0;
    {
        std::vector<std::thread> threads;
        for (int i = 0; i < num_threads; i++)
        {
            threads.emplace_back(worker_with_lock, iterations);
        }
        for (auto &t : threads)
            t.join();
    }
    std::cout << "加锁后结果: " << counter << std::endl;

    return0;
}

输出结果如下:

不加锁结果: 129275
加锁后结果: 400000

再复杂一点的情况,如果有两个或多个线程(或进程)在等待对方释放资源,导致它们永远无法继续执行的状态,此时就产生死锁

用更系统的词概括,死锁的必要条件为以下四点:

  • 互斥条件:资源一次只能被一个线程占用。
  • 占有并等待:线程持有至少一个资源,并等待获取其他被占用的资源。
  • 不可剥夺:线程持有的资源在未使用完前不能被强制释放。
  • 循环等待:存在一个线程循环链,每个线程等待链中下一个线程持有的资源。

打破上面任意一点条件,就可以避免死锁的发生,比如做好资源分配,调整线程唤醒时间等操作。

除了互斥锁之外,还有自旋锁、读写锁等不同的锁:

  • 互斥锁加锁失败后,线程会释放CPU,给其他线程; 自旋锁加锁失败后,线程会忙等待,直到它拿到锁。
  • 读写锁是用来进一步区分当前线程是处于读状态还是写状态。

进一步拓展,互斥锁、自旋锁、读写锁可以归属为悲观锁,即行事较为保守,访问共享资源前,先上锁。

与之相对,乐观锁就是先不上锁,让所有线程先修改完再说,再验证修改期间有没有冲突。

总结

特征

进程

线程

协程

内存空间

独立

共享进程空间

共享线程空间

调度者

操作系统

操作系统

用户态运行时

上下文切换成本

高(切换虚拟内存、PCB)

中(切换寄存器、TCB)

低(栈指针与寄存器)

通信方式

IPC(管道、消息队列、共享内存、Socket)

共享内存 + 同步机制

共享变量,运行时调度

并行能力

可利用多核

可利用多核

单线程运行,需要配合线程/进程

典型应用

数据库、浏览器、操作系统服务

Web 服务器、并行计算

高并发 I/O、异步框架

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言