网络编程5

read函数

读磁盘

读磁盘时,如果缓冲区没有数据了,read不阻塞,直接返回0。可以把read的返回值作为循环退出的条件。

读管道/设备/socket

读管道、设备、socket时候,如果缓冲区没有数据了,read阻塞。不可以把read的返回值作为循环退出的条件。

while里面不能写阻塞式的read

改造成非阻塞的

永久改造

cmd 填 F_GETFL 获取文件状态

cmd 填 F_SETFL ...要填一个 int 参数,设置 fd 的文件状态

cs 复制代码
void setNOnblock(int fd){
    int f1 = fcntl(fd,F_GETFL);//获取当前文件状态
    fd |= O_NONBLOCK;//在原本文件状态的基础上新增一个非阻塞状态
    fcntl(fd,F_SETFL,f1);//设置新的文件状态
}

sockfd临时非阻塞

五种IO模型

参考书《Unix网络编程》

同步阻塞模型

cs 复制代码
while(1){
    recv(fd1);
    do sth1......
    recv(fd2);
    do sth2......
    ......
}

recv()默认情况下是阻塞的。

同步非阻塞模型

cs 复制代码
while(1){
    recv(fd1,MSG_DONTWAIT);
    if(ready){
        do sth1......
    }
    recv(fd2,MSG_DONTWAIT);
    if(ready){
        do sth2......
    }
}

不会出现前面资源没有就绪导致后面已就绪资源阻塞。但是此时是用户在轮询,占用cpu

同步IO多路复用模型

cs 复制代码
while(1){
    epoll_wait/select
    if(ready){
        do sth......
    }
}

轮询交给内核,只要有资源就绪就可以运行,比同步非阻塞模型性能更好。

两种异步模型

异步模型

cs 复制代码
void func(){
    aio_read(fd1,callback1);
    do sth1......
    aio_read(fd1,callback2);
    do sth2......
}

aio_read()不会阻塞,会在一瞬间完成,将来有一天数据到达后,由操作系统调用callback。收到数据和do sth没有明确的关系。理论上,异步模型比IO多路复用模型性能更好,实际上两者差不多,但是异步模型比IO多路复用更复杂、使用较少。

信号驱动IO模型

将callback调用换成发信号。

epoll触发方式

水平触发、边缘触发。

默认的epoll_wait()是水平触发。

更改为边缘触发:

cs 复制代码
event.data.fd = netfd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,&event);

现在边缘触发不好,可能会有数据残留问题,可以将一次recv换成多次recv,recv循环如下:

cs 复制代码
while(1){
    recv(netfd,buf,sizeof(buf),MSG_DONWAIT);
}

水平触发(LT - Level-Triggered)

触发条件: 只要文件描述符处于就绪状态(即满足监听的条件,例如 socket 接收缓冲区中有数据可读,或者发送缓冲区有空间可写),epoll_wait 就会持续报告该事件。

行为: 想象一个水平放置的水杯(状态)。只要水位高于某个标记(就绪状态),系统就会不断提醒你:"水杯有水!水杯有水!"。

后果:

  • 如果你收到一个读就绪通知(EPOLLIN)后,没有一次性把接收缓冲区里的所有数据读完(缓冲区里还有数据),下一次调用 epoll_wait 时,它仍然会再次通知你该 fd 可读。
  • 如果你收到一个写就绪通知(EPOLLOUT)后,没有一次性把数据写完(发送缓冲区还有空间),下一次调用 epoll_wait 时,它仍然会再次通知你该 fd 可写(只要缓冲区没满)。

优点:

  • 编程模型相对简单。应用程序不必担心一次没有处理完所有事件而导致事件丢失。你可以选择处理部分数据,下次 epoll_wait 还会提醒你处理剩余的数据。

缺点:

  • 如果应用程序处理事件的速度较慢,或者故意不处理完,可能会导致 epoll_wait 不断返回同一个就绪的 fd,造成不必要的系统调用和 CPU 开销("惊群效应"的一种表现形式,尤其在 fd 很多但活跃度不高时)。

边缘触发(ET - Edge-Triggered)

diyic边缘触发必须使用非阻塞IO。

触发条件: 仅在文件描述符的状态发生改变时(从未就绪变为就绪),epoll_wait 才会报告一次该事件。

行为: 想象一个电灯开关的边缘(变化)。只有当你按下开关的瞬间(状态从关变为开),系统才会提醒你一次:"灯开了!"。之后灯一直开着,系统不会再提醒你。

后果:

  • 如果收到一个读就绪通知(EPOLLIN),你必须一次性把接收缓冲区里的所有数据读完,直到发生 EAGAINEWOULDBLOCK 错误(表示内核缓冲区暂时空了)。如果你只读了一部分数据,那么即使缓冲区里还有数据,下次 epoll_wait 也不会再通知你这个 fd 可读,直到有新的数据到达(触发下一次状态从未就绪到就绪的变化)。
  • 同理,对于写就绪(EPOLLOUT),如果你收到通知后没有一次性写满发送缓冲区(或者没有新数据要写),那么之后即使发送缓冲区有空位,epoll_wait 也不会再通知你(除非你之前写操作返回了 EAGAIN/EWOULDBLOCK 导致 fd 变为未就绪,然后缓冲区空出空间时才会再次触发状态变化)。

优点:

  • 减少了不必要的通知次数,尤其是在高并发、活跃连接很多但每个连接数据量不大的场景下,可以显著减少 epoll_wait 返回的次数和系统调用开销,理论上性能更高。
  • 更接近底层硬件中断(如网络接口卡)的工作方式。

缺点:

  • 编程模型更复杂,要求应用程序必须一次处理完所有可用数据(循环读取/写入直到 EAGAIN),否则会丢失事件。
  • 强制要求使用非阻塞(non-blocking) I/O。因为你需要在一个通知内循环读写直到 EAGAIN,如果是阻塞 I/O,在读完所有数据前就可能阻塞住线程,导致其他事件无法处理。
  • 更容易出错,例如忘记循环读取导致数据积压在缓冲区而得不到处理。

水平触发vs边缘触发

旧版本的epoll只支持边缘触发。

性能问题:

场景一:epoll_wait和recv是同一个线程,为了更容易发现残留数据,优先使用水平触发。

场景二:epoll_wait和recv不是同一个线程,为了避免重复唤醒,优先使用边缘触发。

服务端框架

服务端应用,一个应用程序需要管理多个连接同时收发数据。

同时的实现思路:

  1. 依赖于多进程或多线程的并发性
  2. IO多路复用

基于线程或基于进程

一开始只有一个主线程或者主进程;这时候有一个客户端俩姐主线程或主进程,随后fork()或者pthread_creat(),由这个线程或者进程处理连接,由子线程或者进程处理服务端业务。由于线程,进程之间的互斥,有良好的性能。

优势:

  1. 代码简洁
  2. 业务开发简单,一个线程阻塞不影响其他线程

劣势:性能特别差

  1. 每一个连接要占用一个线程或者进程,在总内存一定时,连接量少,并发量低
  2. 线程数量远多于cpu数量,上下文切换很频繁
  3. 调整资源分配策略很麻烦

Apache、旧版的Tomcat采用的这种方式,QPS2000以内。QPS(Queries Per Second)是每秒查询率,用于衡量系统、服务或数据库每秒能够处理的查询请求数量。

C10K问题

最初的服务器是基于进程/线程模型。新到来一个TCP连接,就需要分配一个进程。假如有C10K,就需要创建1W个进程,可想而知单机是无法承受的。

使用IO多路复用解决,同时也解决了C100K问题。

C10M问题由DPDK解决。

AI 瓶颈:数据不够复杂,没有足够对应难度复杂的数据用来训练 AI ,需要大量的高水平人才喂 AI 数据。而不是算力上面的瓶颈。

事件驱动模型

我们将

cs 复制代码
while(1){
    select/epoll_wait;
    do sth......
}

称为事件循环。(event loop 事件循环)。
优势:

  1. 每一个连接一个文件对象,高并发量
  2. 线程只有一个,没有CPU切换开销
  3. 资源分配由用户代码决定

事件驱动模型可以解决C10K问题。

cs 复制代码
while(1){
    select();
    if(fd1 ready){
        recv1 --> recv2 --> recv3......
    }
}

当有一个业务需要连续recv时,中间的recv可能会卡住,在单线程情况下,阻塞后面所有recv()。在每个select()之间越短越快,因此,在做任何事要就绪以后再执行。

cs 复制代码
while(1){
    select();
    if(fd1){
        recv1 -- > flag
    }
    if(fd2){
        recv2 -- > flag
    }
    if(fd3){
        recv3 -- > flag
    }
}

缺点:

  • 在单线程情况下,一个业务卡住了,会阻塞所有人。

服务端集群

事件驱动模型有局限性

目前的事件驱动模型只有一个线程,不能充分地利用多核CPU。

若是16核心cpu,推荐选择线程数为 16~32 之间。此时16线程适合于cpu密集型应用,线程需要经常占有CPU;32线程(2倍于核心)适用于IO密集型,IO密集型会频繁切换占有CPU,比如网盘类业务。

进程池、线程池

池 Pool

在应用程序启动时候去申请资源,在运行过程中,不申请也不释放资源。如果使用资源,则使用已经申请的,使用完以后不释放,以便后续直接重用。

几种常见的池:

  • 内存池
  • 连接池
  • 进程池
  • 线程池

进程池:提前创建很多进程,进程数量和CPU核心数相关

一种事件驱动模型与进程池结合的模型

主人进程的职责

  1. 创建并管理工人进程
  2. 用epoll去监听sockfd,一旦就绪要执行accept得到netfd
  3. 得到的netfd交给工人进程,后续的业务由工人进程完成

工人进程的职责

无休止的循环

  1. 等待任务的到来(主人发送netfd)
  2. 根据netfd执行业务

如何书写一个"大"项目

大:不只有一个.c项目

多个.c编译完成后,链接成一个可执行文件。其中,必须有一个main()函数作为入口,不能有多个同名函数的定义。

一、构建目录和文件结构

  1. 新建目录,把相关的文件建好。
  2. 弄清楚各个文件的依赖关系(编译、链接)
  3. 写出Makefile

二、设计数据结构

把人类所理解的产品图转化成计算机能够理解的 struct 。

先写头文件

捋清楚工人、主人之间的业务逻辑关系

三、书写业务代码

从数据结构出发,设计业务代码。将产品的业务逻辑转换成数据结构的增删改查。

写函数要注意的事情:

函数不是一个人的事情

  • 用轮子的人------调用者
  • 造轮子------函数定义的实现者

开发前的第一件事情就是定接口(函数声明)。包括:

  • 函数名(动词加名词,比如makeWorker)
  • 返回值(一律用int ,可以显示调用成功还是失败)
  • 参数(数据依赖传入传出参数传递,先设计传入参数,再设计传出参数)

多个程序员一起合作的时候,面向接口编程,而不是面向实现编程 。

在编写程序时,最好使用增量编译,写一个小模块,测试一下,再解决相应问题,而不是等到写差不多了再编译测试。

sendmsg和recvmsg函数(不重要)

当直接在进程之间传递文件描述符 int 时,接收到的进程并不能使用该文件描述符直接打开文件进行操作,此时就需要用到sendmsg和recvmsg。

sendmsg和recvmsg函数除了可以发一般的数据,还可以发送内核数据结构,比如文件对象。

socketpair可以在进程之间建立全双工通信的socket,socketpair的用法几乎和pipe的用法几乎一致;

pipe的限制:fd0只能读,fd1只能写

socketpair没有这个限制,fd0、fd1可读可写

要求:

  1. 传输介质必须是socket,不能是管道
  2. 消息必须放在msghdr里面
  • msg_name和msg_namelen都填NULL和0
  • msg_iov和msg_iovlen,这是消息的正文,必选项
  • msg_control、msg_controllen这是消息的控制字段,可选项

消息的正文

send_msg的特殊之处,可以发送一片离散的数据。每个离散的数据一定有它的起始地址和长度,send_msg记录下首地址和长度

  • msg_iov是数组的名字
  • msg_iovlen是数组的长度

msg_iovi记录了每个碎片的首地址和长度。

消息的控制字段

使用 <man cmsg> 查看

柔性数组:结构体的最后一个成员是一个不确定长度的数组。因此不能定义在栈上,栈上的数据在编译时确定大小,所以只能申请在堆上。

传输文件对象

cmsg_data里面放一个int(指向目标文件对象的fd)

可以用CMSG_LENGTH 求整个结构体长度,随后在堆上申请空间,在得到结构体的首地址后,使用CMSG_DATA求cmsg_data的地址。

代码实现:

sendmsg

cs 复制代码
int sendfd(int sockfd,int fdtosend){
    //准备号一个
    struct msghdr hdr;
    bzero(&hdr,sizeof(hdr));
    //准备正文
    char buf[] = "hello";
    struct iovec vec[1];//离散区域只有一个碎片
    vec[0].iov_base = buf;//碎片首地址
    vec[0].iov_len = 5;//碎片长度
    hdr.msg_iov = vec;//将离散区域和hdr扯上关系
    hdr.msg_iovlen = 1;
    //准备控制字段
    struct cmsghdr *cmsg = (struct cmsghdr *)malloc(CMSG_LEN(sizeof(int)));
    cmsg->cmsg_len = CMSG_LEN(sizeof(int));//已知data 长度长4,求整个结构体长度
    cmsg->cmsg_level = SOL_SOCKET;
    cmsg->cmsg_type = SCM_RIGHTS;//这个选项说明要发送的数据结构是文件对象
    *(int *)CMSG_DATA(cmsg) = fdtosend;//找到data 的首地址,强转成int *,再解引用,再赋值
    hdr.msg_control = cmsg;
    hdr.msg_controllen = CMSG_LEN(sizeof(int));
    sendmsg(sockfd,&hdr,0);
    return 0;
}

recvmsg

cs 复制代码
int recvfd(int sockfd,int *pfdtorecv){
    struct msghdr hdr;
    bzero(&hdr,sizeof(hdr));

    char buf[6] = {0};
    struct iovec vec[1];
    vec[0].iov_base = buf;
    vec[0].iov_len = 5;
    hdr.msg_iov = vec;
    hdr.msg_iovlen = 1;

    struct cmsghdr *cmsg = (struct cmsghdr *)malloc(CMSG_LEN(sizeof(int)));
    cmsg->cmsg_len = CMSG_LEN(sizeof(int));
    cmsg->cmsg_level = SOL_SOCKET;
    cmsg->cmsg_type = SCM_RIGHTS;
    hdr.msg_control = cmsg;
    hdr.msg_controllen = CMSG_LEN(sizeof(int));
    recvmsg(sockfd,&hdr,0);
    printf("buf = %s, fdtorecv = %d\n",buf,*(int *)CMSG_DATA(cmsg));
    *pfdtorecv = *(int *)CMSG_DATA(cmsg);
    return 0;
}

sendmsg和recvmsg效果

实现了跨越进程的dup。假如使用send_msg传递fd = 4到子进程,子进程并不是直接使用fd = 4,而是将最小可用的文件描述符赋给fd,此时父子进程的fd指向同一个文件

示例

现在有一对父子进程,父进程打开file1,然后写入数据,使用sendmsg和recvmsg将文件对象发给子进程,随后子进程需要继续往后面写入数据

cs 复制代码
int main(int argc, char *argv[]){                                  
    
    int fds[2];
    socketpair(AF_LOCAL,SOCK_STREAM,0,fds);
    pid_t pid = fork();
    if(pid>0){
        //father
        close(fds[0]);
        int fd = open("file1",O_RDWR|O_CREAT);
        ERROR_CHECK(fd,-1,"open");
        char buf[] = "this is dad\n";
        write(fd,buf,sizeof(buf));
        sendfd(fds[1],fd);
        wait(NULL);
    }
    else if(pid ==0){
        close(fds[1]);
        int fd;
        recvfd(fds[0],&fd);
        printf("child fd = %d\n",fd);
        char buf[] = "this is child\n";
        write(fd,buf,sizeof(buf));
    }
    return 0;
}

结果:

零拷贝技术

零拷贝技术是尽量减少内核态和用户态之间的数据拷贝

性能优化的流程

  1. 不能过早优化(正确性 > 可读性 > 性能,能做到"代码即注释"就是好代码)
  2. 性能优化的前提是做压力测试,看是否有瓶颈
  3. 只对瓶颈做优化

mmap内存映射

  • addr : 填NULL
  • length : 文件长度(在此之前要调用ftruncate)
  • port : PROT_READ|PROT_WRITE (open O_RDWR)
  • flags : MAP_SHARED
  • fd : 文件对象
  • offset : 填0

客户端知道文件大小

客户端知道文件的边界,可以不用TLV协议

使用mmap发送,可以不用内核态用户态之间的拷贝转换。要准备一篇足够大的内存,文件大小超过2G不适合mmap。

零拷贝传输sendfile

  • out_fd: 可写,一般是socket。
  • in_fd: 可读,不能是socket,只能是磁盘文件。

使用sendfile数据的流向是定死的,是从磁盘到网卡。

进程池的有序退出

在通知进程池终止之后,先做完一些必要工作(保存数据、清理资源),再终止。

在线更新

起一个新的服务器,等旧服务器的进程全部退出

主人进程收到10号信号,然后发送9号信号,杀死其他进程,等待wait回收资源, 实现进程池的退出。

信号

10)SIGUSR1,

handler的参数是定死的,所以工人进程数量和每个进程的pid必须是全局的,现在希望少一点全局变量。

异步拉起同步

  1. 准备一个全局管道
  2. 注册信号,信号产生的时候,往管道里面写入
  3. 用epoll_wait监听管道的读端
  4. 信号产生会导致epoll_wait就绪,在epoll_wait就绪后可以处理退出逻辑(回收资源、保存数据等等)

准备一个全局管道

cs 复制代码
int exitPipe[2];

注册10号型号量,在headler里面往管道写入

cs 复制代码
void handler(int signum){
    printf("signum = %d",signum);
    write(exitPipe[1],"1",1);//写入内容不重要,相当于发了一个通知
}
/* Usage:  */
int main(int argc, char *argv[]){
    
    pipe(exitPipe);
    if(fork()){
        close(exitPipe[0]);
        signal(SIGUSR1,handler);
        wait(NULL);
        printf("parent is going to exit\n");
        exit(0);
    }
    close(exitPipe[1]);
相关推荐
GPU实战笔记1 小时前
本地跑不动 llama.cpp,临时租云 GPU 怎么搭?从启动服务到环境复用
java·服务器·网络·人工智能·深度学习·llama
发量惊人的中年网工2 小时前
2026年DDoS防护方案怎么选?从攻击响应、清洗位置到成本账单,解析全球高防方案
大数据·网络·安全·ddos
黄昏回响4 小时前
软件工程基础知识(五):软件测试详解(下篇)——现代测试实践与论文指导
网络·其他·软件工程·改行学it
万联WANFLOW5 小时前
网络排障:如何从延迟、丢包、带宽定位海外访问异常
网络·架构·业界资讯
zzzyyy5386 小时前
TCP 协议学习笔记:从报文格式到异常处理
linux·网络
心易行者6 小时前
Python在线运行+SQLite数据库实战:0成本搭个人数据后台,5个场景直接套用
前端·网络·人工智能·python
Wang's Blog6 小时前
Java 项目实战: 外卖平台-新增分类与type标志位设计
服务器·项目开发
志栋智能6 小时前
集成是关键:让巡检超自动化融入现有工具链
运维·服务器·数据库·架构·自动化