进程地址空间
先了解进程虚拟地址空间主要分为内科空间和用户空间,内核空间占1G,用户空间主要占3G,用户空间主要是了解栈区,堆区,.bss,.data,常量区,代码段这几个空间。
1.栈区。主要是局部变量,临时变量,函数调用返回指针的内存区域
2.堆区。主要是malloc分配的内存区域
3..bss。全局变量和静态变量未初始化所在区域
4..data。全局变量和静态变量初始化后所在区域
5.常量区和代码段。顾名思义就是存放常量和代码指令的区域。
mm_struct是进程的内存描述符,讲一讲其中几个重要的变量
1、start_code、end_code 分别指向代码段的开始与结尾
2、start_data 和 end_data 分别指向已初始化数据段的开始与结尾
3、start_brk 和 brk 中间是堆内存的位置
4、start_stack 是用户态堆栈的起始地址
需要注意,这里是虚拟地址空间,对于内核而言,是没有用户态的虚拟地址空间的。
进程的几种状态
一个任务(进程或线程)刚创建出来的时候是 TASK_RUNNING 就绪状态,等待调度器的调度。调度器执行 schedule 后,任务获得 CPU 后进入 执行进行运行。当需要等待某个事件的时候,例如阻塞式 read 某个 socket 上的数据,但是数据还没有到达的时候,任务进入 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 状态,任务被阻塞掉。
这里稍微讲一下namespace的作用,可以隔绝不同进程,把一个或多个进程的相关资源指定在同一个 namespace 中。
进程和线程的创建
进程和线程创建方法看起来不一样,实际上都是调用do_fork而实现的。区别在于进程和线程do_fork传入的参数不一样。然后do_fork调用copy_process,在copy_process中进行复制父进程的task_struct,对task_struct进行拷贝,申请pid等操作。
创建进程
files_struct(文件) fs_struct(文件系统信息 ) mm_struct(进程地址空间)是独立的,所以需要创建一份新的,而对于命名空间而言,不用单独创建一份新的。
当 copy_process 执行完毕的时候,表示新进程的一个新的 task_struct 对象就创建出来了。接下来内核会调用 wake_up_new_task 将这个新创建出来的子进程添加到就绪队列中等待调度。
创建线程
创建线程时,因为传入了 task_struct 中各种字段的共享标志位,所以对应 task_struct 的各字段是共享的
进程与线程的共同点
1.都是从父进程创建的,都会调用do_fork这个系统调用。
2.都可以进程并发执行。也都存在同步和竞争的关系。
3.生命周期一致。都可以被创建、调度执行、阻塞、恢复和终止。操作系统为它们管理生命周期的各个阶段。
4.可以通过共享内存,管道和信息传递等进行进程间通信和线程间通信。
进程与线程的区别
1.地址空间:线程共享本进程的地址空间,文件句柄等,而进程之间是独立的地址空间,文件句柄等。
2.资源耗费:进程创建过程中需要拷贝创建地址空间,文件系统信息等,需要耗费大量资源,而线程可共享这些资源,自然在资源上占据优势
3.安全性,由于线程中许多资源是共享的,故安全性不如进程。对资源的管理和保护要求高,不限制开销和效率时,使用多进程。要求效率高,频繁切换时,资源的保护管理要求不是很高时,使用多线程。
4.进程是资源分配的基本单位,线程是CPU调度和执行的基本单位。
常见IO模型
同步阻塞IO
同步非阻塞IO
IO多路复用模型
这里看一看最基础的select流程图
下面列举一下select poll epoll的优点和缺点,解释一下水平触发和边缘触发。
水平触发:在水平触发状态下,只要文件描述符处于就绪状态就会不断触发事件通知。
边缘触发:只有文件描述符发生变化,事件才会发通知给应用程序。
特性 | select | poll | epoll |
---|---|---|---|
优点 | - 广泛兼容性:几乎在所有Unix和类Unix系统上支持。 - 简单易用:API设计相对简单,适合处理少量文件描述符。 | - 无描述符限制:不像select 有1024文件描述符限制,poll 可处理大量描述符。- 灵活性更高:允许动态修改需要监控的文件描述符集。 | - 高效性:对于大量文件描述符,epoll 效率很高,不会因为监控的描述符数量增加而影响性能。- 边沿触发支持: epoll 支持边沿触发(edge-triggered),适合高性能场景。- 内核事件通知机制:内核直接通知事件的到来,无需轮询所有描述符。 |
缺点 | 用户态到内核态的切换需要拷贝句柄,需要许多资源的耗费。 单个进程可以监听的最大文件描述符有限,1024个。 select返回的是含有所有句柄的数组,需要编译整个数组才知道哪个句柄发生了事件 水平触发 | - 性能问题:与 水平触发 | - Linux独有:仅在Linux系统中可用,不具备跨平台性。 - 复杂性:API比 select 和poll 复杂,学习曲线较陡。- 仅适用于文件描述符:不能监控其他类型的事件。 |
select/poll一般只能处理几千个并发连接
服务器进程将tcp连接告诉操作系统,(这里需要有用户态到内核态的句柄复制),操作系统轮询查询哪些套接字有事件发生,查询过后,将句柄数据复制到用户态,让服务器轮询已处理的事件
而epoll将这些分成了三个过程
- epoll_create创建epoll对象
- create_ctl向这个对象添加100万个套接字
- 调用epoll_wait收集发生的事件连接
- 只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者
删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接(边缘触发的好处)。
epoll机制避免创建多个线程监听IO操作,以及切换线程和切换上下文带来的花销,当一个IO操作就绪时,操作系统就会通知应用程序,哪个IO操作可进行读取操作了。多路复通常与非阻塞IO搭配使用,当一个IO操作无法完成时,不会停止程序的运行,而是返回状态码,让程序进行其他的IO操作,保证程序的并发性。多路复用采用事件循环来处理IO操作,可以高效处理IO操作。
异步驱动I/O (Asynchronous I/O, AIO)
异步驱动I/O的核心思想是:进程发起I/O请求后,立即继续执行其他任务,而不必等待I/O操作完成。当I/O操作完成时,操作系统会通知进程并自动将数据从内核复制到用户空间,这整个过程对进程来说是非阻塞的。
信号驱动I/O (Signal-Driven I/O)
信号驱动I/O是一种半异步模型,进程首先调用sigaction
系统调用注册一个信号处理器(通常是SIGIO
),然后对感兴趣的文件描述符设置非阻塞模式。当内核中的数据就绪时,会发送SIGIO
信号通知进程。进程在收到信号后,执行信号处理器中的代码,将数据从内核读入用户空间。读数据的操作是阻塞的,虽然信号通知本身是异步的。
异步驱动I/O与信号驱动I/O的对比
特性 | 异步驱动I/O (AIO) | 信号驱动I/O (Signal-Driven I/O) |
---|---|---|
I/O行为 | 非阻塞:I/O操作发起后进程立即返回,I/O完成后通知。 | 半异步:信号通知非阻塞,实际I/O操作阻塞。 |
通知机制 | 操作系统在I/O操作完成时通知进程。 | 内核在I/O数据就绪时发送SIGIO 信号通知进程。 |
信号处理 | 不依赖信号,进程可以通过回调函数或其他机制获知I/O完成情况。 | 依赖信号处理器,SIGIO 信号触发执行相应处理函数。 |
数据传输 | 操作系统负责完成I/O操作,并将数据传输到用户空间。 | 进程收到信号后需要主动调用读取函数,从内核读数据。 |
I/O阻塞情况 | 完全非阻塞,整个I/O过程对进程来说都是异步的。 | 信号通知是异步的,但数据读取通常是阻塞的。 |
复杂度 | 编程复杂度较高,需要处理I/O完成的回调或通知机制。 | 编程较简单,基于信号处理机制,但信号处理的可靠性和实时性可能不足。 |
适用场景 | 适用于高并发、大量I/O请求的场景,特别是在实时性要求较高的系统中。 | 适用于不需要高并发的简单异步I/O场景。 |
性能 | 高性能,特别适用于大量并发I/O操作。 | 性能中等,信号的触发和处理有一定的开销,且可能存在信号丢失等问题。 |
内核支持 | 需要操作系统内核提供异步I/O支持,例如POSIX AIO或Linux AIO。 | 只需要操作系统支持信号机制即可,大多数Unix-like系统都支持。 |
特性 | 异步驱动I/O (Asynchronous I/O) | 信号驱动I/O (Signal-Driven I/O) |
---|---|---|
优点 | - 完全非阻塞:整个I/O操作对进程是非阻塞的,进程可以自由处理其他任务。 - 高性能:特别适用于高并发、大量I/O请求的场景。 - 内核管理数据传输:操作系统负责数据传输,减少了用户进程的负担。 | - 非阻塞通知:通过信号异步通知进程I/O事件,避免轮询。 - 简单实现:相较于异步I/O,编程简单,只需设置信号处理器和I/O模式。 - 资源消耗低:不需要像异步I/O那样复杂的内核支持。 |
缺点 | - 实现复杂:编程难度大,需要处理I/O完成的回调或事件通知机制。 - 系统依赖性强:依赖操作系统的异步I/O支持,不同平台实现不一致。 | - I/O操作阻塞:尽管信号通知是异步的,但实际的I/O读写操作仍然是阻塞的。 - 信号处理复杂性:信号机制可能不可靠,容易丢失或被其他信号中断。 - 高并发性能有限:在处理大量并发请求时,性能不足。 |
适用场景 | - 适用于需要高并发、大量I/O请求、低延迟的场景。 - 适合对性能要求极高的系统,如网络服务器、数据库等。 | - 适用于简单的异步I/O场景,不需要处理大量并发连接。 - 适合中低负载系统中需要非阻塞通知但对性能要求不高的场景。 |
性能 | - 高效:不阻塞进程,特别适合处理大量I/O操作。 | - 中等:由于读写操作阻塞,性能较异步I/O稍差,适合中小规模I/O操作。 |
依赖性 | - 内核支持要求高:需要操作系统提供异步I/O接口,如POSIX AIO或Linux AIO。 | - 依赖信号机制:只需操作系统支持信号处理,大多数Unix-like系统都支持。 |
编程难度 | - 高:需要处理异步回调、通知和事件驱动的复杂逻辑。 | - 中等:相对简单,只需管理信号处理器,但信号的可靠性和管理较为复杂。 |
进程间通信
因为进程具有独立性,所有的数据操作都会发生写时拷贝,一定要通过中间媒介的方式来进行通信,进程间通信的本质:让不同的进程先看到同一份资源。
管道
- 匿名管道pipe
- 命名管道
名管道的本质是利用了父子共享文件的特征;命名管道的本质是利用了文件路径具有唯一性,让进程看到同一个文件;两种管道类通信方式看到的同一份资源都是文件资源
下面是匿名管道父进程与子进程共享一个管道的示意图。
头文件:#include <unistd.h>
功能:创建一无名管道
原型:int pipe(int fd[2]);
参数fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码
管道分为读端和写端,要注意管道不能一直写而不读,这样操作系统会干掉这个子进程。
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
系统V的共享内存原理
- 操作系统申请一块内存空间
- OS将该内存映射进对应进程的共享区中(堆栈之间)
- OS可以将映射后的虚拟地址返回给用户
通过共享内存内部数据结构的key来判断两个进程是否能看到同一份数据结构,这头通过调用ftork函数实现,只需要保证两个参数一致即可。
shmat接口:将当前进程和内存共享建立关联。
shmdt接口:将当前接口与共享内存失去关联。
shmctl:删除共享内存接口。
直接上代码
编写makefile
CC=gcc
.PHONY:all
all:client server
client:client.c
$(CC) -o $@ $^
server:server.c
$(CC) -o $@ $^
.PHONY:clean
clean:
rm -f client server
comm.h
#pragma once
#include<stdio.h>
#define PATH_NAME " "
#define PROJ_ID 0x6666
#define SIZE 4097
server.c
#include"comm.h"
#include<sys/ipc.h>
#include<sys/shm.h>
#include<sys/types.h>
int main()
{
//创建key值
key_t k =ftok(PATH_NAME,PROJ_ID);//形成key值
if(k<0)
{
perror("ftok");
return 1;
}
printf("my key:%x\n",k);
//创建共享内存
int shmid = shmget(k,SIZE,IPC_CREAT | IPC_EXCL | 0644);//如果目标共享内存不存在,创建,如果存在,则出错返回
if(shmid < 0)
{
perror("shmget");
return 2;
}
printf("shmid:%d\n",shmid);
sleep(5);
//将当前进程和共享内存进行关联
char* start = (char*)shmat(shmid,NULL,0);
printf("server already attach on shared memory\n");
//开始使用共享内存通信了
//...
//共享内存被映射进了地址空间
for( : : )
{
printf("%s\n",start);
sleep(1);
}
sleep(10);
//将当前进程和共享内存去关联
shmdt(start);
printf("server already dattch off shared memory\n");
sleep(5);
//释放共享内存
shmctl(shmid,IPC_RMID,NULL);
return 0;
}
client.c
#include"comm.h"
#include<sys/ipc.h>
#include<sys/shm.h>
#include<sys/types.h>
int main()
{
//获取同一个key
key_t k =ftok(PATH_NAME,PROJ_ID);//形成key值
if(k<0)
{
perror("ftok");
return 1;
}
//printf("%x\n",k);
//不需要自己创建共享内存,获取共享内存
int shmid = shmget(k,SIZE,IPC_CREAT);
if(shmid < 0)
{
perror("shmget");
return 2;
}
//挂接自己到shm
char* start = (char*)shmat(shmid,NULL,0);
sleep(10);
//通信
char c = 'A';
while(c<='Z')
{
start[c-'A'] = c;//向共享内存段写数据
c++;
sleep(2);
}
//去关联
shmdt(start);
//不需要释放共享内存
return 0;
}
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
信号量
临界资源:进程通信的本质是让进程看到同一份资源(内存空间),这一份资源就叫临界资源。
临界区代码:访问临界资源的代码叫做临界区代码。
信号量本质是一个计数器,申请信号量操作成为P操作,归还信号量操作称为V操作。
Lock();//保证操作是原子的
count++;//释放
Unlock();//保证操作是原子的
不同进程访问不同内存的区域