文章

操作系统高频总结四

堆和栈的区别。什么情况下会往堆里放

堆和栈的区别

堆和栈是程序运行时管理内存的两种主要区域,它们有不同的特点和使用场景。以下是堆和栈的详细对比:


1. 定义与分配方式

特性堆(Heap)栈(Stack)
定义用于动态分配的内存区域,由程序员手动管理。用于函数调用的内存区域,由系统自动管理。
分配方式动态分配,通过 malloc/freenew/delete静态分配,系统在函数调用时分配和释放。

2. 管理方式

特性堆(Heap)栈(Stack)
管理方式程序员负责分配和释放,易出现泄漏。系统自动分配和释放,无需程序员管理。
生命周期由程序员控制,可以跨函数使用。随函数的调用和返回自动销毁。

3. 速度和性能

特性堆(Heap)栈(Stack)
分配速度较慢,需要系统调用管理。快速,分配内存是连续的。
碎片化容易发生内存碎片化。无内存碎片,连续分配。

4. 空间限制

特性堆(Heap)栈(Stack)
大小限制受可用内存或操作系统限制(通常较大)。通常较小(几 MB 到几十 MB)。

5. 数据访问方式

特性堆(Heap)栈(Stack)
存储方式非连续,内存地址通过指针访问。连续分配,数据在栈顶按 LIFO 访问。
数据存储内容动态分配的对象和数据。局部变量、函数参数、返回地址等。

6. 常见问题

问题堆(Heap)栈(Stack)
内存泄漏如果未正确释放动态分配的内存,会泄漏。无内存泄漏,由系统自动管理。
栈溢出无,除非动态分配过多导致系统内存耗尽。递归过深或局部变量过大可能导致溢出。

什么情况下需要使用堆?

使用堆的主要原因是需要 动态分配内存,特别是在以下情况下:

1. 数据大小在编译时无法确定

  • 当需要在运行时决定数据的大小时,必须使用堆。

  • 示例:

    1
    2
    3
    
    int size;
    std::cin >> size;
    int* array = new int[size]; // 动态分配数组
    

2. 数据需要跨函数使用

  • 栈上的数据在函数返回后被销毁,堆上的数据可以被多个函数共享。

  • 示例:

    1
    2
    3
    4
    
    int* createArray() {
        int* array = new int[10]; // 动态分配在堆上
        return array;
    }
    

3. 数据的生命周期需要灵活控制

  • 如果数据的生命周期超出函数范围,或需要延迟释放内存,则应使用堆。

  • 示例:

    1
    
    std::vector<int>* dynamicVector = new std::vector<int>(); // 自由控制生命周期
    

4. 数据量较大

  • 栈的空间有限,大量数据需要放到堆中。

  • 示例:

    1
    
    double* largeArray = new double[1000000]; // 放在堆中避免栈溢出
    

5. 数据结构需要动态调整

  • 动态数据结构(如链表、树、图等)通常需要堆内存。

  • 示例:

    1
    2
    3
    4
    5
    
    struct Node {
        int value;
        Node* next;
    };
    Node* head = new Node{10, nullptr}; // 链表节点在堆中分配
    

堆和栈的选择建议

  1. 优先使用栈
    • 栈分配更快、性能更高,且避免内存泄漏。
    • 适用于小型局部变量和函数调用。
  2. 使用堆的场景
    • 数据量大或大小不确定。
    • 数据需要跨函数或线程共享。
    • 动态数据结构的实现。
  3. 注意堆内存管理
    • 每次分配内存后确保释放,避免内存泄漏。
    • 使用智能指针(如 std::unique_ptrstd::shared_ptr)来自动管理内存。

总结

场景使用内存类型
数据量较小,局部变量
数据量大或大小动态变化
数据需要跨函数、线程或动态数据结构
数据生命周期与函数调用相同

通过正确选择堆或栈,可以显著提升程序的性能和稳定性,同时减少常见内存问题(如内存泄漏、栈溢出等)。

fork函数返回值是怎么实现的

Unix/Linux 系统中fork() 函数是用于创建子进程的系统调用,它的返回值设计独特且高效:

  1. 返回值的意义
    • 父进程 中,fork() 返回子进程的进程 ID(PID)。
    • 子进程 中,fork() 返回 0
    • 如果 fork() 失败,返回 -1
  2. 实现原理
    • fork() 的返回值通过 进程控制块(PCB)和寄存器 的不同管理实现。

1. fork() 的核心原理

(1) 子进程的创建过程

  • 当调用 fork()

    时,操作系统会执行以下步骤:

    1. 复制进程控制块(PCB):
      • 为子进程创建一个新的 PCB,记录子进程的 PID 等信息。
    2. 复制内存空间:
      • 利用 写时拷贝(Copy-On-Write, COW) 技术,父子进程共享同一块物理内存,直到某一方进行写操作。
    3. 复制文件描述符表:
      • 子进程继承父进程打开的文件描述符。
    4. 初始化子进程的寄存器:
      • 子进程的寄存器会被设置为与父进程一致的状态。

(2) 返回值的处理

  • 操作系统在 fork() 返回点的实现上区分了父进程和子进程:
    1. 在 父进程:
      • 操作系统将子进程的 PID 写入父进程的返回寄存器中。
    2. 在 子进程:
      • 操作系统将 0 写入子进程的返回寄存器中。

2. fork() 的返回值在硬件上的实现

(1) 寄存器的作用

  • 在大多数架构(如 x86、ARM)中,函数调用的返回值通常存储在特定的寄存器中:
    • x86 架构:返回值存储在 EAX 寄存器中。
    • x86-64 架构:返回值存储在 RAX 寄存器中。
    • ARM 架构:返回值存储在 R0 寄存器中。

(2) fork() 的不同返回值实现

  • 操作系统在创建子进程时,会根据以下逻辑调整返回值:
    1. 在父进程中:
      • 操作系统在执行 fork() 后,将子进程的 PID 写入父进程的返回寄存器(如 EAXRAX)。
    2. 在子进程中:
      • 操作系统直接将 0 写入子进程的返回寄存器。
    3. 错误情况下:
      • 如果 fork() 失败,返回 -1 并设置全局变量 errno 表示错误原因。

3. 示例代码分析

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid = fork(); // 调用 fork() 创建子进程

    if (pid > 0) {
        // 父进程
        printf("Parent process: PID = %d, Child PID = %d\n", getpid(), pid);
    } else if (pid == 0) {
        // 子进程
        printf("Child process: PID = %d\n", getpid());
    } else {
        // fork() 失败
        perror("fork failed");
    }
    return 0;
}

运行结果

1
2
Parent process: PID = 12345, Child PID = 12346
Child process: PID = 12346

运行分析

  1. 父进程的 fork() 返回值为子进程的 PID(12346)。
  2. 子进程的 fork() 返回值为 0
  3. 如果创建失败,则 fork() 返回 -1

4. 系统调用的实现细节

(1) fork() 的系统调用路径

  • 在 Linux 中,fork()

    是通过以下路径实现的:

    1. C 函数库:
      • 用户调用的 fork() 函数由标准 C 函数库(如 glibc)提供。
    2. 系统调用接口:
      • C 函数库通过系统调用号调用内核中的 clone() 函数。
    3. 内核实现:
      • 内核中的 do_fork() 函数完成实际的进程创建。

(2) 核心内核函数:do_fork()

  • do_fork() 是 Linux 内核中用于创建子进程的函数。
  • 关键步骤:
    1. 分配新的任务结构(Task Struct)。
    2. 复制父进程的虚拟地址空间(页表)。
    3. 初始化子进程的寄存器状态。

(3) 返回值处理

  • 内核根据当前运行的上下文:
    • 如果是父进程上下文,将子进程 PID 返回。
    • 如果是子进程上下文,返回 0

5. 特殊注意事项

(1) 多线程情况下的 fork()

  • 如果在多线程程序中调用 fork(),只有调用 fork() 的线程会在子进程中继续执行,其他线程会终止。

(2) vfork()

  • vfork()fork()的优化版本:
    • 在子进程调用 exec()exit() 之前,父进程会被阻塞。
    • 子进程直接共享父进程的地址空间,避免了页表复制,性能更高。

6. 总结

  • fork() 的返回值是通过操作系统 在父子进程的返回点 上对返回寄存器进行不同设置实现的。
  • 父进程 获取子进程的 PID,子进程 获取返回值 0,从而使两个进程可以根据返回值执行不同逻辑。
  • 这一机制是通过内核函数 do_fork() 的多次分支处理实现的,确保高效创建和管理进程。

用户级线程和内核级线程的区别

用户级线程(User-Level Thread, ULT)内核级线程(Kernel-Level Thread, KLT) 是计算机多线程编程中的两种主要线程模型。它们在实现方式、管理机制和性能等方面有显著区别。


1. 用户级线程(ULT)

特点

  • 线程的创建、管理和调度由 用户空间的线程库 实现,而非操作系统内核。
  • 操作系统的内核并不知道用户级线程的存在。

实现

  • 用户级线程通过线程库(如 POSIX pthread 用户级实现)模拟多线程行为。
  • 每个用户级线程在内核中仅对应一个进程(单内核线程)。

优点

  1. 创建和切换速度快:
    • 线程管理在用户态完成,无需内核态切换,减少上下文切换开销。
  2. 跨平台性好:
    • 不依赖操作系统的线程实现,可以在不同平台上移植。
  3. 灵活性:
    • 用户可以自定义线程调度策略。

缺点

  1. 不能充分利用多核处理器:
    • 一个用户级进程只有一个内核线程,因此无法并行运行。
  2. 阻塞问题:
    • 如果一个线程调用了阻塞系统调用(如 I/O 操作),整个进程都会被阻塞,因为内核无法感知其他线程的存在。

2. 内核级线程(KLT)

特点

  • 线程的创建、管理和调度由操作系统内核负责。
  • 每个线程由内核维护独立的线程控制块(Thread Control Block, TCB)。

实现

  • 操作系统直接支持线程(如 Linux 的 clone() 或 Windows 的线程 API)。
  • 每个内核级线程可以单独分配 CPU 核心运行。

优点

  1. 支持并行执行:
    • 多个线程可以同时运行在多个 CPU 核心上。
  2. 不受阻塞系统调用影响:
    • 一个线程阻塞不会影响同一进程中的其他线程。
  3. 更强的线程隔离:
    • 内核级线程有独立的上下文和调度策略,更适合复杂应用场景。

缺点

  1. 上下文切换开销大:
    • 每次线程切换都需要从用户态切换到内核态。
  2. 创建和销毁开销高:
    • 内核级线程的创建和销毁涉及内核资源管理,效率低于用户级线程。

3. ULT 和 KLT 的对比

特性用户级线程(ULT)内核级线程(KLT)
管理和调度在用户态完成,依赖线程库。在内核态完成,由操作系统管理。
操作系统感知内核无法感知用户级线程。内核直接管理每个线程。
性能线程操作开销低,切换速度快。线程切换开销大,需要内核态切换。
阻塞影响一个线程阻塞会导致整个进程阻塞。一个线程阻塞不影响其他线程。
并行性无法充分利用多核处理器。支持多线程在多个 CPU 核心上并行运行。
移植性高,依赖用户级实现,跨平台性好。依赖操作系统的线程实现,移植性较差。
适用场景I/O 密集型、单核任务。多核并行计算、高性能并发。

4. 混合线程模型(Hybrid Model)

  • 定义
    • 混合线程模型结合了用户级线程和内核级线程的优点。
    • 通过 M:N 映射,M 个用户级线程可以映射到 N 个内核级线程上运行。
  • 特点
    • 用户级线程通过内核线程实现并行。
    • 线程管理可以部分在用户态完成,减少系统调用开销。
  • 应用
    • Java 虚拟机(JVM)的某些实现。
    • 现代多线程库如 GNU pthreads

5. 使用场景选择

用户级线程的适用场景

  1. 单核系统:
    • 在单核处理器上,用户级线程效率更高。
  2. 轻量级任务:
    • 需要快速创建和销毁线程,且不依赖多核并行。
  3. 不涉及阻塞系统调用:
    • 适用于 CPU 密集型计算任务。

内核级线程的适用场景

  1. 多核并行:
    • 需要充分利用多核处理器的场景。
  2. I/O 密集型任务:
    • 涉及大量阻塞系统调用(如文件读写、网络通信)。
  3. 高性能并发:
    • 需要线程隔离和更强的并发性能。

6. 总结

模型优点缺点适用场景
用户级线程快速、高效、跨平台性好。无法利用多核,阻塞会影响整个进程。单核系统、轻量任务、无阻塞调用的场景。
内核级线程支持多核并行,线程隔离强。开销大,依赖操作系统支持。多核并行、高性能并发、I/O 密集型任务。

在实际开发中,可以根据需求选择用户级线程或内核级线程,或者结合两者使用以达到最佳性能和灵活性。

线程池和线程开销

线程池 是一种高效管理线程资源的技术,旨在减少线程创建和销毁的开销,从而提升多线程程序的性能。


线程的开销

1. 线程创建的开销

  • 资源分配:
    • 每个线程需要独立的栈空间(通常几 MB)和线程控制块(Thread Control Block, TCB)。
  • 系统调用:
    • 线程创建涉及操作系统内核(如 Linux 的 clone() 系统调用),需要切换到内核态,耗费资源。
  • 初始化:
    • 初始化线程上下文(寄存器、堆栈指针等)。

2. 线程销毁的开销

  • 资源回收:
    • 线程终止时,操作系统需要回收其栈空间和线程控制块。
  • 同步等待:
    • 主线程可能需要通过 join 等方式等待子线程完成,增加同步开销。

3. 线程切换的开销

  • 上下文切换:
    • 多线程程序中,线程之间的切换涉及保存和恢复线程的寄存器、程序计数器等上下文信息。
    • 如果线程切换发生在不同的 CPU 核心之间,还需要刷新缓存(如 L1/L2 缓存)。
  • 频繁切换的开销:
    • 如果线程数量过多,操作系统的调度开销会显著增加,影响程序性能。

线程池的概念

1. 什么是线程池?

  • 线程池是一种预创建线程并对其进行管理的机制。
  • 在线程池中,线程的创建和销毁是受控的,任务由固定数量的线程处理,而不是为每个任务单独创建线程。

2. 线程池的工作流程

  1. 初始化线程池:
    • 在程序启动时创建固定数量的线程,并进入空闲状态。
  2. 任务提交:
    • 将任务提交到线程池的任务队列中。
  3. 线程执行:
    • 空闲线程从任务队列中取出任务执行。
  4. 线程复用:
    • 线程完成任务后返回线程池,继续等待下一个任务。

3. 线程池的核心组件

  • 任务队列:
    • 存储等待执行的任务。
  • 工作线程:
    • 从任务队列中取任务并执行。
  • 线程管理器:
    • 管理线程池的创建、销毁和任务调度。

线程池的优点

1. 性能提升

  • 减少创建和销毁开销:
    • 线程池复用了线程,避免了频繁的线程创建和销毁。
  • 降低系统调用频率:
    • 线程的创建和销毁涉及系统调用,线程池通过复用减少了这种开销。

2. 提高系统稳定性

  • 控制线程数量:
    • 线程池限制了并发线程的数量,防止因线程过多导致的系统资源耗尽。
  • 任务排队:
    • 超过线程池容量的任务会被放入队列,按顺序执行。

3. 易于管理

  • 线程池提供统一的接口,可以方便地提交和管理任务。

线程池的开销

1. 初始化开销

  • 线程池在创建时需要分配线程和初始化任务队列。

2. 任务调度开销

  • 每个任务的分发需要线程池管理器从任务队列中取任务,并分配给工作线程。
  • 任务队列的锁竞争可能导致一定的性能损失。

3. 内存开销

  • 线程池需要维护线程控制块和任务队列,消耗一定的内存资源。

线程池的实现

以下是一个简单的 C++ 线程池实现示例:

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <iostream>
#include <vector>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <atomic>

class ThreadPool {
public:
    ThreadPool(size_t thread_count) : stop(false) {
        for (size_t i = 0; i < thread_count; ++i) {
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;

                    {
                        std::unique_lock<std::mutex> lock(this->queue_mutex);
                        this->condition.wait(lock, [this] { return this->stop || !this->tasks.empty(); });
                        if (this->stop && this->tasks.empty()) return;

                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }

                    task();
                }
            });
        }
    }

    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            stop = true;
        }
        condition.notify_all();
        for (std::thread &worker : workers) {
            worker.join();
        }
    }

    template <class F, class... Args>
    void enqueue(F&& f, Args&&... args) {
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            tasks.emplace([f, args...] { f(args...); });
        }
        condition.notify_one();
    }

private:
    std::vector<std::thread> workers;              // 工作线程
    std::queue<std::function<void()>> tasks;       // 任务队列
    std::mutex queue_mutex;                        // 任务队列互斥锁
    std::condition_variable condition;            // 条件变量
    std::atomic<bool> stop;                        // 停止标志
};

void printTask(int id) {
    std::cout << "Task " << id << " is running in thread " << std::this_thread::get_id() << std::endl;
}

int main() {
    ThreadPool pool(4); // 创建包含 4 个线程的线程池

    for (int i = 0; i < 10; ++i) {
        pool.enqueue(printTask, i); // 向线程池提交任务
    }

    std::this_thread::sleep_for(std::chrono::seconds(1)); // 等待所有任务完成
    return 0;
}

线程池的适用场景

  1. 高并发任务:
    • 如 Web 服务器中处理大量客户端请求。
  2. 计算密集型任务:
    • 需要充分利用多核 CPU 的并行计算能力。
  3. 频繁短任务:
    • 适用于创建和销毁线程开销较大的场景。

线程池的限制和优化

1. 限制

  • 任务过载:
    • 如果任务数量超出线程池处理能力,可能导致任务延迟或阻塞。
  • 锁竞争:
    • 任务队列的同步可能引发性能瓶颈。

2. 优化建议

  • 动态调整线程数:
    • 根据任务负载动态增加或减少线程。
  • 分段队列:
    • 将任务队列分为多个段,减少锁竞争。
  • 任务优先级:
    • 实现任务的优先级调度,提高关键任务的处理速度。

总结

线程池的优点线程的开销
减少线程创建和销毁的开销创建和销毁需要系统调用
控制线程数量,防止资源耗尽线程过多可能导致资源耗尽
提高性能和系统稳定性线程切换和上下文切换开销

通过合理使用线程池,可以显著降低多线程程序的资源开销并提高系统性能。

线程切换的到底是什么

线程切换(Thread Context Switch)是操作系统的一种机制,用于在多个线程之间切换执行权,以实现多任务并发。线程切换的本质是操作系统对 线程上下文(Thread Context) 的保存和恢复。


线程切换的核心:线程上下文

线程上下文包含了线程执行所需的所有状态信息,包括:

  1. CPU 寄存器
    • 包括通用寄存器(如 eax, ebx 等)、栈指针(ESP/RSP)、程序计数器(EIP/RIP)等。
    • 切换线程时需要保存当前线程的寄存器值,并恢复目标线程的寄存器值。
  2. 程序计数器(Program Counter)
    • 指向线程下一条将要执行的指令地址。
    • 切换时保存当前线程的指令地址,并加载目标线程的指令地址。
  3. 堆栈指针(Stack Pointer)
    • 每个线程都有自己的栈,用于存储局部变量、函数调用信息等。
    • 切换时保存当前线程的栈指针,并加载目标线程的栈指针。
  4. 线程本地存储(Thread-Local Storage, TLS)
    • 每个线程的私有数据,例如使用 thread_local 声明的变量。
  5. 内核数据结构
    • 包括线程的 线程控制块(Thread Control Block, TCB),记录了线程的状态、优先级、调度信息等。

线程切换的过程

线程切换一般分为以下几个步骤:

  1. 保存当前线程的上下文
    • 操作系统保存当前线程的 CPU 寄存器、程序计数器、堆栈指针等信息到其线程控制块(TCB)。
  2. 更新调度器信息
    • 操作系统的调度器根据调度算法(如时间片轮转、优先级调度等)选择下一个要运行的线程。
  3. 加载目标线程的上下文
    • 从目标线程的 TCB 中恢复其 CPU 寄存器、程序计数器、堆栈指针等。
  4. 切换到目标线程
    • 操作系统将 CPU 控制权交给目标线程,使其继续执行。

线程切换的成本

线程切换需要在保存和恢复线程上下文之间进行多次操作,因此会带来以下开销:

  1. CPU 开销
    • 保存和恢复寄存器状态。
    • 更新调度器数据。
  2. 内存开销
    • 保存线程的堆栈和上下文状态。
  3. 缓存失效
    • 切换到新的线程后,CPU 缓存中的数据可能需要重新加载。
  4. 操作系统干预
    • 线程切换通常需要操作系统内核的参与,导致用户态与内核态的频繁切换。

线程切换 vs 进程切换

线程切换与进程切换非常相似,但线程切换的开销通常小于进程切换,主要区别在于:

  1. 地址空间
    • 线程共享同一进程的地址空间,因此线程切换无需切换虚拟内存。
    • 进程切换需要切换地址空间(页表),开销更大。
  2. 资源共享
    • 线程共享同一进程的资源(如文件描述符、全局变量等)。
    • 进程间资源是独立的,切换时需要额外处理资源。

线程切换的优化

  1. 减少上下文切换
    • 增大线程的时间片,减少切换频率。
    • 使用线程绑定 CPU 核心(CPU affinity)优化线程调度。
  2. 避免锁竞争
    • 尽量减少线程之间的同步操作,降低阻塞切换的可能性。
  3. 使用用户态线程(User-Level Threads)
    • 例如协程(Coroutine),由用户程序管理线程切换,减少操作系统内核参与。

通过理解线程切换的机制和开销,可以更好地优化多线程程序的性能和效率。

本文由作者按照 CC BY 4.0 进行授权