八股文——系统调用与进程管理详解,map和set

系统调用与进程管理详解,map和set

    • 一、select函数详解
      • 1.1 什么是select
      • 1.2 函数原型
      • 1.3 参数说明
      • 1.4 返回值
      • 1.5 文件描述符的数量限制
      • 1.6 就绪文件描述符的轮询扫描方式
      • 1.7 内核/用户空间内存拷贝
      • 1.8 select的触发方式
      • 1.9 select的优缺点
        • 优点:
        • 缺点:
      • 1.10 各种I/O多路复用方案比较
    • 二、Unix/Linux进程管理基础
      • 2.1 fork — 创建子进程
        • 2.1.1 fork()的工作方式
        • 2.1.2 示例代码
        • 2.1.3 执行结果(可能会不同,每次运行都会生成不同的PID)
      • 2.2 wait — 等待子进程结束
        • 2.2.1 wait()的作用
        • 2.2.2 示例代码
        • 2.2.3 执行结果
      • 2.3 exec — 替换进程映像
        • 2.3.1 常见的exec函数
        • 2.3.2 示例代码
        • 2.3.3 执行结果
      • 2.4 进程相关系统调用总结
      • 2.5 综合示例
    • 三、位运算算法:计算二进制中0的个数
      • 3.1 问题描述
      • 3.2 代码实现
      • 3.3 算法分析
        • 3.3.1 核心算法:位运算
        • 3.3.2 举例说明
    • 四、map和set
      • 1. **map和set的区别**
      • 2. **底层实现:**
      • 3. **常见操作:**
      • 4. **注意点:**
      • 5. **总结:**
    • 四、总结

一、select函数详解

1.1 什么是select

select是Unix类操作系统中的一个系统调用,主要用于同时监视多个文件描述符(file descriptor, fd),等待其中的一个或多个变为"就绪"状态,以便进行I/O操作(如读、写)。它是实现多路复用I/O(Multiplexed I/O)的经典方法。

1.2 函数原型

#include 
#include 
#include 
#include 

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

1.3 参数说明

  • nfds最大文件描述符值 + 1,告诉select需要检查的文件描述符范围
  • readfds读文件描述符集合,用于监视是否有可读事件
  • writefds写文件描述符集合,用于监视是否有可写事件
  • exceptfds异常文件描述符集合,用于监视异常情况(如OOB数据)
  • timeout超时时间,控制select的等待时间:
    • NULL:阻塞,直到至少有一个文件描述符就绪
    • {0, 0}:非阻塞,立即返回结果
    • 其他值:等待指定的时间后返回

1.4 返回值

  • >0:表示有可读、可写或异常的文件描述符就绪
  • 0:超时,没有文件描述符就绪
  • -1:出错,通常是EINTR(被信号中断)

1.5 文件描述符的数量限制

单个进程可以监视的文件描述符数量通常受限于FD_SETSIZE,在Linux头文件中定义:

#define __FD_SETSIZE 1024
  • 默认情况下,select只能监视最多1024个文件描述符
  • 可以修改FD_SETSIZE,但需要重新编译相关的应用程序和库
  • 更推荐使用pollepoll,它们不受此限制

1.6 就绪文件描述符的轮询扫描方式

select返回的是就绪文件描述符的数量int),但不会告诉你哪些fd发生了事件。因此,应用程序需要手动遍历fd_set,检查哪些fd发生了事件:

for (int i = 0; i < nfds; i++) {
    if (FD_ISSET(i, &readfds)) {
        // 该fd发生了可读事件
    }
}

由于select采用轮询的方式扫描所有文件描述符,这意味着:

  • 文件描述符数量越多,遍历的开销越大,性能越差
  • 时间复杂度为O(n),当监视的fd很多时,性能较低

1.7 内核/用户空间内存拷贝

select每次调用时,都会从用户空间向内核空间拷贝fd_set,然后再从内核空间拷贝回用户空间。这带来了以下问题:

  • 数据结构每次都要重置,需要重新FD_SET(),否则数据会被修改
  • 拷贝过程消耗CPU资源,当fd数量较多时,性能开销很大

相比之下,epoll采用事件驱动模型,避免了这种拷贝开销,性能更优。

1.8 select的触发方式

select采用水平触发(Level Trigger, LT)

  • 如果fd处于就绪状态但未被处理,select仍然会不断返回该fd
  • 例如:
    1. fd可读,应用程序调用select,它返回fd可读
    2. 但如果程序没有读取数据,那么下一次select调用仍然会返回fd可读

相对而言,epoll支持边缘触发(Edge Trigger, ET),只在状态变化时通知,减少了重复通知,提高了效率。

1.9 select的优缺点

优点:
  1. 可移植性好

    • select是POSIX标准,几乎所有Unix/Linux以及Windows都支持它
    • epollkqueue等其他多路复用机制在某些系统上可能不可用
  2. 超时时间精度更高

    • selecttimeout精确到微秒(us)
    • poll只能精确到毫秒(ms)
缺点:
  1. 文件描述符数量受限

    • 只能监视FD_SETSIZE(通常是1024)个fd
    • pollepoll不受此限制
  2. 每次调用都要拷贝fd_set

    • 用户态与内核态的拷贝开销大,影响性能
  3. 遍历整个fd_set,效率低

    • select不会告诉你哪个fd发生了事件,应用程序必须遍历所有fd,时间复杂度O(n)
    • epoll采用事件回调机制,时间复杂度接近O(1),性能更优

1.10 各种I/O多路复用方案比较

方案 文件描述符限制 拷贝开销 遍历方式 触发模式
select 1024 轮询 O(n) 水平触发(LT)
poll 无限制 轮询 O(n) 水平触发(LT)
epoll 无限制 事件通知 O(1) 水平触发/边缘触发
  • select适用于少量fd的场景,如:

    • 早期Unix系统,或需要兼容Windows
    • 简单的多路复用场景,代码易于理解
  • 大规模fd监听,推荐使用epoll(Linux)或kqueue(BSD/macOS)

    • 服务器高并发场景,如Nginx、Redis、数据库等

如果你有更大规模的连接需求,建议学习epoll,它能显著提升性能。

二、Unix/Linux进程管理基础

2.1 fork — 创建子进程

fork()是Unix/Linux中的一个系统调用,它的作用是创建一个新的进程(子进程)。新进程是调用进程的一个副本,但有自己的进程ID(PID)。父进程和子进程几乎完全相同,但是它们是独立的,可以分别执行不同的任务。

2.1.1 fork()的工作方式
  • fork()会复制当前进程,创建一个几乎相同的新进程
  • 父进程中,fork()的返回值是子进程的PID(大于0)
  • 子进程中,fork()的返回值是0
  • 如果fork()失败,返回-1,表示进程创建失败
2.1.2 示例代码
#include 
#include  // 提供fork()函数
#include  // 提供pid_t类型

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

    if (pid > 0) {
        // 这里是父进程
        printf("我是父进程,我的PID是%d,我的子进程PID是%d\n", getpid(), pid);
    } else if (pid == 0) {
        // 这里是子进程
        printf("我是子进程,我的PID是%d,我的父进程PID是%d\n", getpid(), getppid());
    } else {
        // fork失败
        printf("fork失败!\n");
    }

    return 0;
}
2.1.3 执行结果(可能会不同,每次运行都会生成不同的PID)
我是父进程,我的PID是1234,我的子进程PID是1235
我是子进程,我的PID是1235,我的父进程PID是1234

解释:

  • fork()之后,进程会被复制,因此if语句后的代码会执行两次,一次在父进程里,一次在子进程里
  • 父进程获取子进程的PID(pid > 0
  • 子进程获取fork()返回的0pid == 0
  • fork()只是复制进程,父子进程的执行顺序是不确定的,由操作系统调度

2.2 wait — 等待子进程结束

父进程可以使用wait()waitpid()等待子进程结束,避免子进程变成"僵尸进程"(Zombie Process)。

2.2.1 wait()的作用
  • wait()使父进程阻塞,直到某个子进程结束
  • 成功时返回子进程的PID
  • 如果没有子进程,或者所有子进程都已经结束,则返回-1
2.2.2 示例代码
#include 
#include 
#include 
#include 

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

    if (pid > 0) {
        // 父进程
        printf("父进程等待子进程结束...\n");
        int status;
        pid_t child_pid = wait(&status); // 等待子进程
        printf("子进程%d结束,退出状态%d\n", child_pid, WEXITSTATUS(status));
    } else if (pid == 0) {
        // 子进程
        printf("我是子进程,我的PID是%d\n", getpid());
        sleep(2); // 模拟子进程运行2秒
        printf("子进程即将退出...\n");
    } else {
        printf("fork失败!\n");
    }

    return 0;
}
2.2.3 执行结果
父进程等待子进程结束...
我是子进程,我的PID是1235
子进程即将退出...
子进程1235结束,退出状态0

解释:

  • wait(&status)让父进程阻塞,直到子进程退出
  • 子进程运行2秒后退出,父进程继续执行
  • WEXITSTATUS(status)获取子进程的退出状态

2.3 exec — 替换进程映像

exec系列函数用于在进程中执行一个新程序,替换当前进程映像。exec执行成功后,当前进程的代码和数据都会被新程序替换,它不会返回

2.3.1 常见的exec函数

exec系列有多个变体:

  • execl(path, arg0, arg1, ..., NULL)传递参数列表(l代表list)
  • execv(path, argv[])传递参数数组(v代表vector)
  • execle(path, arg0, ..., NULL, envp)传递环境变量
  • execve(path, argv[], envp[])直接使用参数数组和环境变量数组
2.3.2 示例代码
#include 
#include 

int main() {
    printf("当前进程PID: %d\n", getpid());
    
    // 执行ls命令
    execl("/bin/ls", "ls", "-l", NULL);
    
    // 只有exec失败才会执行这里
    printf("exec执行失败!\n");
    return 0;
}
2.3.3 执行结果
当前进程PID: 1234
total 8
-rw-r--r-- 1 user user 1234 Mar 20 12:00 example.txt

解释:

  • execl("/bin/ls", "ls", "-l", NULL)执行ls -l命令
  • exec成功后,当前进程的代码被新程序替换,后面的printf()不会执行
  • 如果exec失败,则会执行printf("exec执行失败!")

2.4 进程相关系统调用总结

系统调用 作用 返回值 特点
fork() 创建子进程 父进程返回子进程PID,子进程返回0,失败返回-1 父子进程独立运行
wait() 父进程等待子进程 成功返回子进程PID,失败返回-1 防止僵尸进程
exec() 执行新程序 成功无返回,失败返回-1 替换当前进程

2.5 综合示例

创建子进程,等待子进程执行exec,然后回收资源

#include 
#include 
#include 
#include 

int main() {
    pid_t pid = fork();

    if (pid > 0) {
        // 父进程
        wait(NULL);
        printf("子进程结束,父进程退出。\n");
    } else if (pid == 0) {
        // 子进程执行ls命令
        execl("/bin/ls", "ls", "-l", NULL);
    } else {
        printf("fork失败!\n");
    }

    return 0;
}

执行效果

total 8
-rw-r--r-- 1 user user 1234 Mar 20 12:00 example.txt
子进程结束,父进程退出。

三、位运算算法:计算二进制中0的个数

下面是一个有趣的算法,用于计算一个整数二进制表示中0的个数。

3.1 问题描述

编写一个函数,计算整数num的二进制表示中有多少个0。

例如:

  • 输入:num = 25
  • 二进制表示:0001 1001
  • 输出:3(因为25的二进制表示中有三个0)

3.2 代码实现

#include 
#include 

// 计算num的二进制表示中0的个数
int CountZeroBit(int num) {
    int count = 0;  // 初始化计数器,记录0的个数
    
    // 只要num不为0,循环就会继续
    while (num + 1) { 
        count++;  // 每次找到一个0位,计数器加1
        num |= (num + 1);  // 将num的0位转化为1
    }
    
    return count;  // 返回0的个数
}

int main() {
    int value = 25;  // 25的二进制表示是 0001 1001
    int ret = CountZeroBit(value);  // 计算0的个数
    printf("%d的二进制位中0的个数为%d\n", value, ret);  // 输出结果
    system("pause");
    return 0;
}

3.3 算法分析

CountZeroBit函数的核心思想是使用位运算技巧来找出二进制表示中的0位。

3.3.1 核心算法:位运算
while (num + 1) {
    count++;  // 每次找到一个0位,计数器加1
    num |= (num + 1);  // 将num的0位转化为1
}
  • num + 1:这一步将num的最低位0变为1。如果num的最低位是1,它会将num的最低位1变为0,并且将下一个更低的0位变为1。

    例如:

    • 如果num = 1000,那么num + 1 = 1001
    • 如果num = 1110,那么num + 1 = 1111
  • num |= (num + 1):通过这个操作,我们将num中的最低0位转化为1。|=是位或操作符,将num + 1的结果和num按位或,结果就是将最低的0变成了1。

3.3.2 举例说明

假设num = 25(其二进制表示为0001 1001),我们要逐步找出其中的0位。

  1. 初始化

    • num = 250001 1001
    • count = 0(初始时没有找到任何0)
  2. 第一次循环

    • num + 1 = 260001 1010
    • num |= 26num = 0001 1001 | 0001 1010 = 0001 1011。这样,num的第二个0变成了1。
    • count++count变成了1。
  3. 第二次循环

    • num + 1 = 270001 1011
    • num |= 27num = 0001 1011 | 0001 1100 = 0001 1111。这样,num的第三个0变成了1。
    • count++count变成了2。
  4. 第三次循环

    • num + 1 = 280001 1100
    • num |= 28num = 0001 1100 | 0001 1101 = 0001 1101。这样,num的最后一个0变成了1。
    • count++count变成了3。
  5. 结束条件

    • num = 290001 1101),这时没有更多的0了,循环结束。

最终,count的值是3,表示25的二进制表示中有3个0。

四、map和set

1. map和set的区别

  • 数据结构:

    • map 是一个键值对集合,即每个元素由一个 key(键)和一个 value(值)组成。它允许通过 key 来访问 value
    • set 是一个只包含 key 的集合,没有值,只有唯一的关键字。每个元素在 set 中只能存在一次。
  • 支持下标操作:

    • map 支持通过下标(即operator[])来访问和修改元素的值,如果指定的键不存在,operator[] 会默认插入一个新元素。
    • set 不支持通过下标来访问或修改元素。它只提供了插入、删除等操作。
  • 允许修改元素:

    • map 允许修改 value,但是不允许修改 key,因为一旦修改了 key,就可能破坏其在 map 中的顺序和结构。
    • set 的元素是不可修改的,因为每个元素都是 key,修改会影响集合的有序性,因此其迭代器是常量迭代器。

2. 底层实现:

  • mapset 都是基于 红黑树(RB-Tree) 实现的,红黑树是一种自平衡的二叉查找树,能保证插入、删除和查找操作的时间复杂度为 O(log n)
  • 由于是红黑树,mapset 中的元素会自动按照键(对于 mapkey,对于 set 是唯一的 key)排序。
  • mapset 提供的操作(如插入、查找、删除等)都直接映射到红黑树的相应操作,如插入一个节点、查找某个节点、删除节点等。

3. 常见操作:

  • map 示例:

    map 中,每个元素是一个 键值对(key-value)。你可以使用 operator[] 来访问或插入一个键值对。若某个键不存在,operator[] 会插入一个新元素。

    #include 
    #include 
    
    int main() {
        std::map<std::string, int> age;
    
        // 插入元素
        age["Alice"] = 30;
        age["Bob"] = 25;
        age["Charlie"] = 35;
    
        // 输出map的内容
        std::cout << "Map contents:" << std::endl;
        for (const auto& pair : age) {
            std::cout << pair.first << ": " << pair.second << std::endl;
        }
    
        return 0;
    }
    

    输出:

    Map contents:
    Alice: 30
    Bob: 25
    Charlie: 35
    
  • set 示例:

    set 中,每个元素是一个唯一的 key,插入操作会自动排除重复的元素。由于 set 没有 operator[],它只提供插入、删除和查找操作。

    #include 
    #include 
    
    int main() {
        std::set<int> numbers;
    
        // 插入元素
        numbers.insert(10);
        numbers.insert(20);
        numbers.insert(30);
        numbers.insert(20);  // set中不会插入重复元素
    
        // 输出set的内容
        std::cout << "\nSet contents:" << std::endl;
        for (const auto& num : numbers) {
            std::cout << num << std::endl;
        }
    
        return 0;
    }
    

    输出:

    Set contents:
    10
    20
    30
    

4. 注意点:

  • map的下标运算符

    • operator[]map 中不仅用于访问元素,还可以用于插入元素。若所查找的 key 不存在,它会自动插入一个值为默认构造的 mapped_type 的新元素。
    • 在使用时,如果你只关心查找元素是否存在,而不希望自动插入,可以使用 find() 方法:
    if (age.find("David") == age.end()) {
        std::cout << "David not found" << std::endl;
    }
    
  • set的特性

    • set 中的元素是自动排序的,插入的元素会按照 key 排序。且 set 不允许插入重复的元素。
    • 使用 set 时可以直接遍历其元素,而不能修改元素的值,因为 set 是基于有序集合的实现。

5. 总结:

  • map 用于存储 key-value 键值对,支持下标操作并且允许修改 value,但不能修改 key
  • set 用于存储唯一的 key 集合,元素不可修改,并且自动排序。

四、总结

本文详细介绍了Unix/Linux系统编程中几个重要的概念:

  1. select系统调用:用于I/O多路复用,适合监控少量文件描述符的情况。
  2. fork、wait、exec系统调用:用于进程管理和控制,是Unix/Linux多任务处理的基础。
  3. 位运算算法:展示了如何使用位运算技巧来解决二进制计算问题。
  4. map和set:如何使用容器map和set。

你可能感兴趣的:(C++学习,学习笔记,c++,开发语言)