首页 项目相关
文章
取消

项目相关

项目相关

UDP TCP

UDP不需要监听,自然服务端没有listen,UDP是无连接,在无连接需求下自然客户端没有connect,服务端也没有accept

  • UDP可以调用connect函数。但是和TCP意义不同。UDP使用connect之后,内核仅仅会把对端ip&port记录下来。这样链接就变成了一对一的。也就是可以使用TCP的那一套系统调用了。但是UDP自己本身还是UDP,依旧不可靠。但是此时效率会变高,因为普通的UDP在一对多的时候因为没有记录端口和地址,则每次需要调用sendto函数。参数更多。但是在一对一的时候只需要使用send。这样传入的参数变少了,这意味着不需要重复分配和释放内存储存连接信息,可以永久维护一个链接信息的结构。
  • https://blog.51cto.com/u_15346415/3674455

unknown

同步模型

例子:你是一个老师,让学生做作业,学生做完作业后收作业。

  • 同步阻塞:逐个收作业,先收A,再收B,接着是C、D,如果有一个学生还未做完,则你会等到他写完,然后才继续收下一个
  • 同步非阻塞:逐个收作业,先收A,再收B,接着是C、D,如果有一个学生还未做完,则你会跳过该学生,继续去收下一个,收完了一圈再过来问学生写完没,如果没写完就再问一圈。
  • select/poll:学生写完了作业会举手,但是你不知道是谁举手,需要一个个的去询问
  • epoll:学生写完了作业会举手,你知道是谁举手,你直接去收作业

同步阻塞:

服务器执行到accept的时候,会阻塞,等待直到建立连接为止。服务器执行到receive(Read)部分的时候,也会阻塞,等待客户端发送数据。直到客户端发送完数据了才能继续。在这期间所有其他客户端想要建立链接都是不可以的,因为被阻塞在了客户端的connect这里。对于这种情况我们可以为每一个connect新建一个线程/进程。但是会消耗大量资源。

写的部分也一样。会阻塞。

单线程:某个 socket 阻塞,会影响到其他 socket 处理。

多线程:客户端较多时,会造成资源浪费,全部 socket 中可能每个时刻只有几个就绪。同时,线程的调度、上下文切换乃至它们占用的内存,可能都会成为瓶颈。多线程解决的方式是在主线程accept后,新开一个线程对应一个客户端,读和写都在新的线程执行。

  • 一个socket(文件描述符)是否设置为阻塞模式,只会影响到connect/accept/send/recv等四个socketAPI函数,不会影响到 select/poll/epoll_wait函数,后三个函数的超时或者阻塞时间是由其函数自身参数控制的。

同步非阻塞(忙轮询)

我们可以把文件描述符设为非阻塞。文件描述符设置为非阻塞可以解决accept/receive(read)/send(write)的阻塞。在非阻塞模式,所有的建立链接/读/写如果不能执行,则不会阻塞而是继续(循环)执行。所以我们可以把监听文件描述符设置为非阻塞来解决accept阻塞,也可以把读写文件描述符设置为非阻塞解决read write的阻塞。当监听文件描述符检测到连接后,既可以在主线程处理后续逻辑,也可以单开新线程进行处理。但是这种循环执行有个问题。每次循环都需要调用系统调用来向内核询问是否有数据就绪,会频繁切换内核态和用户态。浪费资源。

同步非阻塞和异步的区别

  • 非阻塞I/O要不停的调用I/O函数来检查数据是否来,如果数据来了,就得卡在I/O函数这里把数据从内核缓冲区复制到用户缓冲区,然后这个函数才能返回
  • 异步I/O根本不需要不停的调用I/O函数来检查数据是否到来,只需要调用一次,然后就可以干别的事情去了
  • 没用用到特殊API的都是同步。
  • select,poll, epoll都是同步IO

IO多路复用

SELECT 底层是数组

首先,SELECT会拷贝一份我们需要监听的文件描述符集合到内核空间。然后内核帮助我们进行遍历,判断对应的文件描述符是否有修改动作。如果有就给对应的位置置为1,没有就会置为0哪怕原来是1。最后返回有几个事件就绪。所以这个文件描述符集合每次会被修改。这就是为什么我们代码有两份文件描述符集合。但是假如我们有4个客户端 100 101 102 103需要检测。所以100-103的位置都是1。我们如果此时只有100 和 101 被修改了,那么这个数组里面将只有100 和 101是1。 102和103将会被置为0,不会被继续监听。所以我们有两个数组。一个是原始的只可以被set和clr进行手动设置和归零的。另一个是给内核的,内核可以修改的。所以我们select的时候给内核可修改版本。set和clr还是修改原始版本。然后在每一次的while循环一开始,更新内核版本为原来的需要监听的那几个。

由于是内核帮助我们进行遍历,然后修改原文件描述符数组。所以最后我们还是要自己遍历一遍文件描述符数组找到到底哪几个文件描述符就绪了。也就是会重复遍历一次。最后我们还需要将自己原来的监听文件描述符数组拷贝回给让内核修改的那份(入参的那一份)

优点:

  • 不需要每个 FD 都进行一次系统调用,解决了我们自己使用同步非阻塞的时候频繁使用系统调用导致的频繁的用户态内核态切换问题

缺点:

  • 有文件描述符数组的拷贝动作,(从用户空间拷贝到内核空间)消耗很大。而且是直接修改后拷贝回用户空间(整个数组全都拷贝)。所以需要两个数组,每次循环后都需要把我们自己保留的一份拷贝给内核的那一份里面。
  • 有最大描述符限制
  • 内核帮助我们遍历文件描述符数组的时候是线性遍历。所以效率较低
  • select调用后,我们还是需要再次遍历一次文件描述符数组,找出具体修改的文件描述符。

SELECT的文件描述符数组其实是bitmap。

请注意。我们所谓的拷贝至内核指的是,将函数的参数拷贝至内核栈。普通参数传递的时候是直接把参数值入栈(用户态或者内核态)。但是系统调用的时候,由于内核不能相信任何用户空间的指针,所以会先把参数写入至寄存器,然后再把参数从寄存器拷贝至内核栈。所以我们SELECT会先把数组拷贝到内核空间,修改后再拷贝回用户空间。

POLL 底层是链表

select的两个区别

  • 去掉了最大监听描述符数量的1024限制
  • 不再需要每次重置监听描述符数组(重新赋值)因为POLL的监听文件描述符数组实际上每个元素是储存了多种信息的结构体,类似epoll
  • POLL依旧有把FD数组拷贝至内核 和 回参需要遍历的问题。
1
2
3
4
5
struct pollfd{
    int		fd;			//委托检测的文件描述符
    short	events;		//委托检测的事件
    short	revents;	//实际发生的事件
}

内核会修改revents而不会修改events。这是主要区别

EPOLL 底层是红黑树

使用EPOLL的时候我们会先使用epoll_create创建一个epoll实例(eventpoll)。但是这个实例被创建在了内核区。创建后会返回一个epoll的文件描述符。我们正是通过这个文件描述符操作这个epoll实例。

  • 我们所有要求epoll监听的文件描述符被储存在红黑树内。

  • 有一个就绪列表,用来添加所有已经就绪的文件描述符。这是一个双向链表(也有人说是队列)。这里之后会把里面的东西复制回用户空间的一个用于接收就绪文件描述符的数组(epoll_wait的第二个参数)。
  • 有一个等待队列。如果调用时没有时间就绪,就会阻塞,放入等待队列让出CPU以便后续唤醒。

我们每次使用epoll_ctl将一个需要监听的文件描述符添加至eventpoll的时候,会被封装成epitem后拷贝至内核空间,仅需拷贝一次。因为他会一直存在在里面。

  • 每一个epitem也有一个等待队列,它会关联至一个回调函数(ep_callback)。也就是这个epitem对应的文件描述符就绪时,会调用这个回调函数来唤醒进程。
  • epoll_wait的作用是获取就绪的文件描述符。所以项目里面这个函数是放在while里面的。因为每次获取就绪的文件。
    • epoll_wait形参没有poll或者是select的那种传入的监听文件描述符数组了。而是我们之前创建的epoll文件描述符。所以没有数组拷贝这个流程。

接收流程

  • 服务端通过网卡接收到客户端消息。
  • 网卡通过DMA写入内存。
  • 发送中断信号给CPU,表示有数据到达。
  • CPU调用中断处理程序处理。通过数据包的IP和端口号找到对应的socket套接字(文件描述符)。
  • 将数据放入对应socket(文件描述符)的接收队列。
  • 处理后会找到对应epitem的等待队列关联的回调函数(ep_poll_callback)。
  • 回调函数会将epitem添加至就绪列表。
  • 唤醒在等待队列中的进程。(如果进程处于睡眠状态)
  • 进程判断就绪列表是否有就绪事件。
  • 如果有就绪事件,拷贝至用户空间的一个用于接收就绪文件描述符的数组(epoll_wait的第二个参数)。

epoll仅当添加监听的文件描述符(拷贝至内核空间) 和 有对应的就绪事件时(拷贝回用户空间)会发生拷贝。

优点

  • epoll对象一直被维护在内核态,所以仅有添加文件描述符时需要进行拷贝
  • 有就绪列表,所以可直接获知就绪事件(文件描述符)。无需重复遍历
  • 监听文件描述符数组储存在红黑树,搜索/添加/删除速度快

缺点:

  • 仅支持linux。跨平台性差。
  • 比select复杂,移植性差。
  • 在监听连接数和事件较少的情况下,select/poll可能更优。因为比较简单。

LT(水平)/ET(边缘)触发区别(其实就是回调函数触发次数的区别)

LT模式的时候,只要epoll_wait检测到事件没有被处理完毕(比如没有读完),那么后续每次epoll_wait调用都会通知。(只要缓冲区有数据就一直触发)

ET模式的时候,epoll_wait检测到事件后,仅通知一次。直到下次再次检测到事件后才继续通知。(就算没读完,也要等到下次新的事件到来后才能继续处理)(直到缓冲区数据有增加(变化)才会触发)

ET模式下可以通知很多次。监听socket不用设置为oneshot是因为只存在一个主线程去操作这个监听socket

我们为什么使用ET

ET减少了重复触发次数,效率会高一些。

ONESHOT

oneshot指的某socket对应的fd事件最多只能被检测一次,不论你设置的是读写还是异常。

因为可能存在这种情况:如果epoll检测到了读事件,数据读完交给一个子线程去处理,

如果该线程处理的很慢,在此期间epoll在该socket上又检测到了读事件,则又给了另一个线程去处理,

则在同一时间会存在两个工作线程操作同一个socket。

EPOLLONESHOT这种方法,可以在epoll上注册这个事件,注册这个事件后,如果在处理完毕当前的socket后不再重新注册相关事件,

那么这个事件就不再响应了或者说触发了。

当处理完毕想要通知epoll可以再次处理的时候就要调用epoll_ctl重新注册(重置)文件描述符上的事件。这样前面的socket就不会出现竞态

也就是说注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕, 该线程就应该立即重置这个 socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。

https://www.jxhs.me/2021/04/08/linux%E5%86%85%E6%A0%B8Epoll-%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86/

为什么使用ET一定要设置为非阻塞

因为我们ET是一个事件只通知一次,所以为了效率我们必须一次性读完或者写完。我们会在一个单独的while里面进行循环读或者循环写。但是如果我们是阻塞的文件描述符,假设我们在读。我们循环了四次读完了,然后发现没有东西读了。就会一直卡在这直到有新的东西能读。也就是阻塞读的while循环没有退出条件。我们如果设置为非阻塞的话,发现没有数据了会返回一个ERRNO,这样就是有退出条件了。我们就可以break退出循环。写也是一个道理。

尤其是我们这里模拟了preactor模式,用的是主线程进行消息的读取主线程把消息读完了交给子线程去解析,子线程写好了打包好了数据后交给主线程发送。所以read的时候如果用了阻塞的文件描述符,读完了就一直卡在那,也无法接受新链接。彻底卡死。所以设置为非阻塞读,当没有数据的时候不会卡在那,会返回一个错误代码,我们根据错误代码来退出读取的while循环。

而且,如果是阻塞的,不循环读的话会干扰下一次发送的数据,会有上次读不完的和下一次的数据混在一起的事情发生。注意,阻塞与否是要看有没有while。阻塞的核心原因是read函数在阻塞模式下,循环读的话没有退出条件。所以:

ET模式下:

  • 阻塞不循环:干扰下一次发送的数据
  • 阻塞循环:卡死

所以一定要设置非阻塞,然后循环读取。这样既可以保证一次性读完,不会发生数据混乱,也保证了有退出条件。

LT模式下:

因为LT是每次都触发,所以我们不需要设置循环读。我们只要告诉他每次读取几个字节即可。他会一直每次都读取对应的字节数量直到读取完毕。

  • 阻塞:一直读直到读完。没问题
  • 非阻塞:一样

所以LT模式没什么特别区别

如果把监听文件描述符listenFD设置为边缘触发(ET)的话应该怎么办

如果这样的话,我们也需要把listenFD外面加一个while循环来循环accept所有的链接,直到返回-1而且errno == EAGAIN。不然的话高并发的时候你只处理一个之后他直到新的链接进来都不会通知。服务器无法处理,会堵在这,这样会丢链接。

LT模式的监听文件描述符只要accept返回不是错误就可以直接建立链接,然后等待下次通知。(只要存在就通知)

recv和send函数作用

recv和send的作用只是按照规则拷贝而已。从socket缓冲区拷贝到用户缓冲区而已。不管进程是否调用recv()读取socket,对端发来的数据都会经由内核接收并且缓存到socket的内核接收缓冲区之中。recv()所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的buffer里面,并返回,仅此而已。send()也一样。所以send将数据拷贝至socket内核发送缓冲区(协议的缓冲区)后会直接返回,而这个时候数据不一定已经成功发送,因为send只负责拷贝。发送是TCP的事情。

read/write readv/writev 和 recv/send 都是IO函数。

recv/sendread/write的功能稍多。

注意看好了这里和TCP的联系!!!

socket内核接受缓冲区被TCP用来缓存网络上接收到的数据,一直保存到应用进程读走为止。如果应用进程一直没有调用read或者recv读取,数据会一直缓存在socket的接受缓冲区内。接受缓冲区满了以后,发生的动作是:接收端通知发送端,接收窗口关闭(win=0)。这个便是滑动窗口上的实现。保证TCP套接口接受缓冲区不会溢出,从而保证了TCP是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。这就是TCP的流量控制,如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方TCP将丢弃它。

注意:

  • I/O缓冲区在每个TCP套接字中单独存在;
  • I/O缓冲区在创建套接字时自动生成;
  • 即使关闭套接字也会继续传送输出缓冲区中遗留的数据;
  • 关闭套接字将丢失输入缓冲区中的数据。

recv的工作原理:

  • recv先检查套接字的接收缓冲区,如果该接收缓冲区中没有数据或者协议正在接收数据,那么recv就一直等待,直到协议把数据接收完毕。当协议把数据接收完毕,recv函数就把套接字的接收缓冲区中的数据拷贝到用户层的buffer中,(注意:协议接收到的数据可能大于用户区的buffer的长度,所以在这种情况下,要调用几次recv函数才能把套接字接收缓冲区中的数据拷贝完。或者是需要接收的数据比socket的buffer还要大,那么需要循环读取,每次读取空出socket的buffer后才能继续接收。)recv函数仅仅是拷贝数据,真正的接收数据是协议来完成的。
  • recv函数返回其实际拷贝的字节数。如果recv在拷贝时出错,那么就返回SOCKET_ERROR;如果recv函数在等待协议接收数据时网络中断了,那么它返回0。对方优雅的关闭socket并不影响本地recv的正常接收数据,如果协议缓冲区内没有数据,recv返回0,指示对方关闭;如果协议缓冲区有数据,则返回对应数据(可能需要多次recv),在最后一次recv时,返回0,指示对方关闭。
    • 所以我们项目里面在读取的时候有一个下标m_read_index = m_read_index + bytes_read;是因为可能多次调用recv读取然后拼接到缓冲区上。
    • 循环recv的原因是我们不知道具体有多少东西要发送过来。我们需要非阻塞循环读取直到返回-1即没有东西可以读取了。

recv返回值

  • 0 表示读到了EOF也就是对端关闭
  • -1 表示发生错误
    • 我们项目中因为是非阻塞所以错误号为EAGAINEWOULDBLOCKEINTR表示读完了就跳出。
  • 其他就是接受的数据的字节数。

send的工作原理

send()函数只负责将数据提交给协议层。当调用该函数时,send()先比较待发送数据的长度和套接字的发送缓冲区的长度:

  • 当待拷贝数据的长度大于发送缓冲区的长度时,该函数返回SOCKET_ERROR;
  • 当待拷贝数据的长度小于或等于发送缓冲区的长度时,那么send先检查协议是否正在发送发送套接字的发送缓冲区中的数据:
    • 如果是就等待协议把数据发送完,再进行拷贝;
    • 如果协议还没有开始发送套接字的发送缓冲区中的数据或者该发送缓冲区中没有数据,那么send就比较该发送缓冲区中的剩余空间和待拷贝数据的长度:
      • 如果待拷贝数据的长度大于剩余空间的大小,send就一直等待协议把该发送缓冲区中的数据发完;
      • 如果待拷贝数据的长度小于剩余空间大小,send就仅仅把buf中的数据拷贝到剩余空间中。(注意:并不是send把该套接字的发送缓冲区中数据传到连接的另一端,而是协议传的,send仅仅是把数据拷贝到该发送缓冲区的剩余空间里面。)
  • 如果send函数拷贝成功,就返回实际拷贝的字节数;如果拷贝的过程中出现错误,send就返SOCKET_ERROR;如果send在等待协议传送数据时网络断开的话,那么send函数也返回SOCKET_ERROR。
  • 要注意,send函数把buffer中的数据成功拷贝到套接字的发送缓冲区中的剩余空间里面后,它就返回了,但是此时这些数据并不一定马上被传到连接的另一端。如果协议在后续的传输过程中出现网络错误的话,那么下一个socket函数就会返回SOCKET_ERROR。(每一个除send外的socket函数在执行的最开始总要先等待套接字的发送缓冲区的数据被协议传送完毕才能继续,如果在等待时出现网络错误,那么该socket函数就返回SOCKET_ERROR。)

服务器模型 Proactor和Reactor模式 (事件处理模式)

Reactor

核心就是主线程只负责监听文件描述上是否有事件发生,有的话就立即将该事件通知工作线程。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。

QQ截图20220731171623

Proactor

俩字:异步

QQ截图20220731171903

模拟Proactor模式

主线程负责监听链接 + 读 + 写。读出来的数据交给子线程处理。子线程处理完毕后交给主线程写出(发送)

QQ截图20220731172002

并发模式 - 半同步半反应堆。就是主线程监听+读写,然后封装成任务对象后插入队列交给子线程处理其余的事情

请求队列用的是链表。因为只需要顺序访问并且需要经常删除和添加。链表速度比较快。

缺点

主线程和子线程共用一个任务队列。主线程和子线程对队列操作都需要加锁,消耗资源较大。

子线程同一时间只能处理一个客户端连接,如果连接数很大,队列会堆积任务导致响应速度越来越慢。就算使用子线程,切换线程也会消耗资源。

客户端断开连接,服务端epoll会监听到EPOLLRDHUP

没有epoll的时候,我们在NET1笔记写了,客户端close发送FIN包,服务器的协议栈会给它替换成一个EOF放到文件描述符里面。我们使用recv读到这个EOF的时候会返回0,我们拿到了这个返回值0就知道需要关闭了,就可以关闭连接了。也就是我们是代码层操作。

有epoll的时候,我前面都一样也会替换成EOF,但是这个时候epoll会检测到。所以会触发EPOLLRDHUP这个事件。也就是现在epoll替我们做了这个事情。

项目存在的问题:

  • 多线程取处理数据用的是信号量。而且信号量只有一个,没有给生产者消费者两个信号量,而是让他俩使用的一个。
  • 这两个可以替换。信号量+锁和条件变量+锁都可以实现对应功能。
  • recv的时候出现严重问题。

    • 假如我的socketbuffer非常小。只有10,header是100。我读完了10之后才能有空间接收下一个10。但是假如现在网卡了,读完了10后while循环读发现没东西,errorno = eagain然后就break跳出了。所以其实根本读不完,所以只能依靠外面解析的时候发现badline之后报错
    • 所以大文件传输应该在传输前设置一个文件大小。如果小于文件大小而且是eagain就继续读取。如果等于文件大小了还是eagain就break跳出。
  • 报错有问题。processread返回多种错误,但是process的时候只判断了NO_REQUEST
  • 如果链接关闭的比定时器早,比如HTTP关闭了keep_alive,则链接会被直接关闭,但是依旧会被放到超时链表,也会检测一遍,最后会关闭一个已经关闭的文件描述符(我们项目里的close_connect中,如果关闭一个对象的文件描述符,会把这个对象的文件描述符关闭后设置为-1)并且返回一个(-1)。虽然没啥事吧但是不太优雅。

信号量

  • 这个sem_init最后的值意思是初始值是几。也不能完全理解为物品容量。信号量后续操作是单纯地对那个数字进行增减。而这个数字只有初始值而没有顶(所以会溢出)。wait会让这个数字减掉1。如果相减后小于0了就阻塞(所以这个数字理论上可以到-1,因为我们如果现在是1,则拿一个资源就是0。因为现在我拿到了资源所以不会被阻塞,然后下一个线程进来了-1,发现结果变成了-1,就会阻塞)
  • 选择题中,这个值可以为负。负数就是目前有多少个进程/线程在等待该资源。(操作系统概念6.6.1)
  • 这个值可能会溢出。也就是初始值假如5,可能变成6,溢出,这样会返回错误 一个错误代码是EOVERFLOW。但是一般操作系统会忽略。
  • post会让睡眠的进程唤醒,如果相加后发现信号量值<=0,意味着有程序被阻塞,则会唤醒对应的线程或者是进程。如果>0则意味着没有进程睡眠

这也就是为什么信号量是先等待(-1),后加锁。因为wait本身是阻塞的,如果小于0了就阻塞。如果先加锁,发现小于0了直接阻塞就没办法解锁了。这也是为什么条件变量要反过来。

  • 假设我们使用的是一个信号量,即消费者和生产者共享信号量。
    • 一个信号量的时候就是,如果数字不为0,就该消费消费该生产生产。如果数字为0了,那么消费者就等着,等生产者生产完了通知后继续消费。
    • 首先,初始化的时候,我们不能让消费者直接消费,所以初始化的值一定是0。(如果生产者消费者区分信号量,则生产者信号量初始值应为队列的最大值,消费者信号量初始值仍旧应为0。
    • 生产者:
      • 加锁
      • 生产
      • 解锁
      • post [+1]
    • 消费者:
      • wait [-1] 一定要先等待。如果上来就锁了,因为wait是阻塞的如果是0就阻塞等待,那生产者也拿不到锁也没办法生产了。
      • 加锁
      • 消费
      • 解锁
  • 假设我们使用的是一个信号量,即消费者和生产者共享信号量。(其实和条件变量差不多)
    • 首先,初始化的时候,我们不能让消费者直接消费,所以初始化的值一定是0。但是生产者可以直接生产。所以初始化的值可以为队列最大值,比如8。
    • 生产者:
      • wait[-1] 注意这个时候是减掉的生产者自己的空位。也就是每生产一次,减掉一个。他最多生产8个,生产多了就停止等待让这个数字不为0。(消费者会+1)
      • 加锁
      • 生产
      • 解锁
      • post [+1]注意这个时候是添加的消费者的消费。让消费者的信号量不为1
    • 消费者:
      • wait[-1] 注意这个时候是减掉的消费者自己的空位。也就是记录有多少可以消费的
      • 加锁
      • 消费
      • 解锁
      • post[+1]注意这个是告诉生产者+1,也就是可生产的空位+1.

条件变量

条件变量一定要先加锁而且必须是可以手动解锁的锁比如unique_lock而不能使用lock_guard。也可以用一个条件变量也可以用两个。核心是pthread_cond_wait。原理是首先调用方抢锁,然后发现需要等待,所以调用方会被阻塞(睡眠并加入等待队列),然后互斥锁解锁,让其余线程抢锁。其余线程如果抢到锁执行完了任务,然后就可以调用notify通知。通知后调用方的wait会重新加锁并唤醒当前进程(之后wait函数返回)。系统保证解锁和睡眠是原子操作。系统也保证加锁和唤醒是原子操作。

  • 为了防止虚假唤醒,必须要使用while而不能使用wait。

因为我们在wait函数返回之前,当前进程必须执行 拿到锁—>加锁并唤醒 这两步。加锁和唤醒是原子的,但是并不一定能拿得到这个锁。假如我们消费者1在等待,然后生产者生产完毕,通知消费者。假如这个时候消费者2进来了,直接就拿了锁(因为生产者释放锁到wait函数拿锁这两步不是原子的。存在这种第三方插进来的情况)然后消费了生产的数据。然后释放锁。这时候我们消费者1终于拿到锁了,但是发现数据已经被消费了,这样再去拿数据会有错误。所以必须用while。也就是判空。

  • 注意条件变量的信号丢失问题。

看看例子:

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
std::condition_variable cv;
 std::mutex gMtx;

void Sender()
{
     std::cout << "Ready Send notification." << std::endl;
     cv.notify_one();   // 发送通知
 }

void Receiver()
{
     std::cout << "Wait for notification." << std::endl;
     std::unique_lock<std::mutex> lck(gMtx);
     cv.wait(lck);    // 等待通知并唤醒继续执行下面的指令
     std::cout << "Process." << std::endl;
}

 int main()
 {
     std::thread sender(Sender);
     std::thread receiver(Receiver);
     sender.join();
     receiver.join();
     return 0;
}

线程随机启动导致的唤醒丢失,即:通信线程先启动并调用通知函数(notify_one),但是接收线程还没有开始执行等待(wait)函数,如果不再次调用函数通知,等待会一直持续下去。这个是最容易发现和验证的问题,上面的主线程中启动线程的顺序就会概率性出现唤醒丢失的问题。

解决方案也比较简单。也是搭配while和判断式。其实和上面解决虚假唤醒的道理一致。只不过要注意使用while

  • std::condition_variable::wait后面的判断式(谓词)的意思是,只要后面的谓词返回false,则前面无论如何都不会解锁。尽管可能已经被通知到。

判断式法:

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
std::condition_variable cv;
std::mutex gMtx;

bool send = false;
void Sender()
{
     std::cout << "Ready Send notification." << std::endl;
     send = true;
     cv.notify_one();   // 发送通知
 }

void Receiver()
{
     std::cout << "Wait for notification." << std::endl;
     std::unique_lock<std::mutex> lck(gMtx);
     cv.wait(lck, [](){return send;});    // 等待通知并唤醒继续执行下面的指令
     std::cout << "Process." << std::endl;
}

int main()
{
    std::thread sender(Sender);
    std::thread receiver(Receiver);
    sender.join();
    receiver.join();
    return 0;
}
  • 上面的谓词可以等同于这种写法:
1
2
3
4
5
6
7
8
9
void Receiver1()
{
     std::cout << "Wait for notification." << std::endl;
     std::unique_lock<std::mutex> lck(gMtx);
     while(send == false){
        cv.wait(lck); // 等待通知并唤醒继续执行下面的指令
     }
     std::cout << "Process." << std::endl;
}

有没有发现和解决虚假唤醒的很像?都是while内有一个判别式,然后循环判断等待。

  • 这个方法可以解决唤醒丢失的原因:
    • 我们的问题在于sender先发送了notify,然后receiver才开始wait。导致丢失
    • 所以在使用上述方法后,就算sender先发送了nofity,但是此时send已经被sender变更为true。所以此时就算唤醒receiver的信号已经丢失,由于sendtrue,所以不会被阻塞。
  • 二者的解决方式都是while+判别式。

  • 同时参考 effective modern c++ 条款39

信号,管道,定时器

我们使用SIGALRMSIGTERM来侦测定时器信息和服务器停止信息。那么我们如何传递呢?

使用管道

我们用socketpair创建管道。然后从管道写端写入信号值(send函数),管道读端注册至epoll,通过epoll监测读事件。设置管道写入端为非阻塞。因为如果设置为阻塞,信号套接字(文件描述符)缓冲区满了的话会阻塞。会增加信号处理的时间。

https://cloud.tencent.com/developer/article/1603781

使用socketpair创建的管道是双向都可读写。

定时器逻辑。

我们main里的第一次alert会在五秒后触发,然后信号捕捉函数捕捉到信号,调用sig_handlersig_handler会往信号管道写入一个SIGALRM数据。然后我们epoll监听到信号文件描述符有事件,放入就绪数组。我们遍历至信号文件描述符的时候判断是SIGALRM或者是SIGTRM

  • 如果是SIGTRM则设置stoptrue后停止服务器。然后我们会delete pool删除线程池,调用线程池析构函数。析构函数内设置线程停止标识为true然后子线程停止。因为是detach所以资源自动回收。
  • 如果是SIGALRM则设置定时变量标志为true。我们不立即执行定时任务因为优先级并不高,先处理其余任务比如文件的读写。当一轮文件描述符遍历处理完毕后再处理定时任务。
    • 定时任务即调用timer_handler由于alarm调用一次只会触发一次,所以函数内仍要设置新的定时器来不断触发信号
      • 第一次在while外面的alarm做为引火器。让我们执行第一次处理信号,因为我们收到了是SIGALRM所以返回true调用此函数。调用此函数后再次设置五秒倒计时。五秒后又会处理信号,又是SIGALRM所以再次调用此函数。如此循环
    • 执行到timer_handler后进入tick。此函数是判断链表里的任务是否超时。遍历链表。因为我们的链表是升序链表。如果当前链表头的任务仍未超时,则break。如果有超时任务,则删除节点并调用回调函数,这个回调函数执行的是:
      • 客户端文件描述符移出epoll
      • 关闭文件描述符

​ 然后一直查找超时任务,直到链表头的任务不超时为止。

  • 更改时间
    • 注意,我们在accept的时候就设置定时器了。所以我们检测到了一个文件描述符有任务了,则把该文件描述符的超时时间重新设置为当前时间+3倍的timeslot。然后调用adjust_timer重新设置。设置逻辑也是按照升序的顺序插入到原链表内。定时器相关操作由主线程执行,所以不会产生共享资源,无需上锁。
  • 复杂度
    • 从实现上看,主要涉及双向链表的插入,删除操作,其中添加定时器的事件复杂度是O(n),删除定时器的事件复杂度是O(1)。
  • 需要补充的地方,项目没写
    • 写的时候也要重新设置定时器。但是咱们只有单次写入就没事
    • 异常的时候也要从链表中删除对应文件描述符。懒得弄

管道传递的是什么类型?

信号本身是整型数值,管道中传递的是ASClI码表中整型数值对应的字符。

switch的变量一般为字符或整型,当switch的变量为字符时,case中可以是字符,也可以是字符对应的ASClIl码。

杂项

  • 统一事件源
    • 具体的,信号处理函数使用管道将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用I/O复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过epoll来监测,从而实现统一处理。
  • 定时事件失效也允许,因为定时任务不是必须立刻处理的。

异步日志

其实我这个异步日志就是单纯的开了一个线程不断的从阻塞队列取出数据然后写入磁盘。

  • 使用了单例模式。多次使用同一个对象不会重复创建,内存中只有一份,大家共享。防止频繁创建导致的内存消耗。

    • 懒汉:真正使用的时候才创建
      • 懒汉在多线程会出现创建多份的情况。虽然后面创建的会覆盖掉前面的,但还要避免。
        • 解决:加锁+双检测。双检锁也可能失效
          • 只用单检测锁的时候每次调用实例的时候都要加锁,影响性能,双检锁的第一层只有在实例没有创建的时候会调用,创建实例后,不是NULL所以不用加锁可以直接返回实例。
      • 或者是使用静态局部变量。
    • 饿汉:加载的时候就创建。会消耗更多资源,因为就算没使用到这个实例,但是只要加载了这个类就会创建。
  • 阻塞队列使用了生产者消费者模型,使用了锁+条件变量。条件变量上面提到了。

  • 再次强调:条件变量先加锁。因为wait函数要解锁。

    • 这里必须只能使用unique_lock不能使用lock_guardlock_guard是阉割版的unique_lock,不支持手动解锁。但是wait函数要解锁,所以只可以使用unique_lock
  • 伪唤醒相关。

  • 写入使用了fflush + fputs
    • fflush()会强迫将缓冲区内的数据写回参数stream指定的文件中,如果参数streamNULL, fflush()会将所有打开的文件数据更新。

    • 在使用多个输出函数连续进行多次输出到控制台时,有可能下一个数据再上一个数据还没输出完毕,还在输出缓冲区中时,下一个printf就把另一个数据加入输出缓冲区,结果冲掉了原来的数据,出现输出错误。在prinf()后加上fflush(stdout);强制马上输出到控制台,可以避免出现上述错误。

  • 可变参数宏。
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
  #define LOG_BASE(level, format, ...) \
      do {\
          Log* log = Log::Instance();\
          if (log->IsOpen() && log->GetLevel() <= level) {\
              log->write(level, format, ##__VA_ARGS__); \
              log->flush();\
          }\
      } while(0);
  
  #define LOG_DEBUG(format, ...) do {LOG_BASE(0, format, ##__VA_ARGS__)} while(0);
  #define LOG_INFO(format, ...) do {LOG_BASE(1, format, ##__VA_ARGS__)} while(0);
  #define LOG_WARN(format, ...) do {LOG_BASE(2, format, ##__VA_ARGS__)} while(0);
  #define LOG_ERROR(format, ...) do {LOG_BASE(3, format, ##__VA_ARGS__)} while(0);
  
  /*
  这里的do while意思是确保宏可以正确调用。因为宏有先替换再计算的特性。可能会出错
  ...这个可变参数列表在宏中和##__VA_ARGS__搭配使用。
  举例子:
  #define LOG_DEBUG(format, ...) do {LOG_BASE(0, format, ##__VA_ARGS__)} while(0);
  这个意思是你输入LOG_DEBUG("HELLO %d\n", 100)
  会被替换成LOG_BASE(0, "HELLO %d\n", 100)
  
  输入LOG_DEBUG("HELLO %d %s %d\n", 100, "test", 10000)
  会被替换成LOG_BASE(0, "HELLO %d %s %d\n", 100, "test", 10000)
  
  
  可变参数的宏里的’##’操作说明带有可变参数的宏(Macros with a Variable Number of Arguments)
  __VA_ARGS__宏前面加上##的作用在于,当可变参数的个数为0时,这里printf参数列表中的##会把前面多余的”,”去掉,否则会编译出错,建议使用后面这种,使得程序更加健壮。
  https://blog.csdn.net/bat67/article/details/77542165
  */
  • 日志分文件
    • 日志写入前会判断当前day是否为创建日志的时间,行数是否超过最大行限制。
      • 若为创建日志时间,写入日志,否则按当前时间创建新log,更新创建时间和行数。
      • 若行数超过最大行限制,在当前日志的末尾加count/max_lines为后缀创建新log将系统信息格式化后输出,具体为:格式化时间+格式化内容

为什么要异步?和同步的区别是什么?

同步方式写入日志时会产生较多的系统调用,如果某条日志信息过大,会阻塞日志系统,造成系统瓶颈。异步系统采用生产者–消费者模型,具有较高的并发能力。

单例模式:

优点:只有一个实例所以不需要每次都创建和销毁,可以在启动的时候就创建对象然后永久驻留在内存中。

实现方式:

  • 私有化构造函数和拷贝构造,拷贝赋值
  • 定义类内静态指针并且设置为私有化
  • 定义一个获取指针的静态函数,返回指向实体的指针。
  • 初始化的时候在类外进行,然后new一个对象赋值给这个指针。
  • 使用的时候使用对应的静态函数,获取指针,访问实例。

缺点:有的实现方式是线程不安全的。

注意事项

  • accept后拿到的读写文件描述符对应的是TCP请求三次握手后服务器接受连接了。然后这个时候不一定有数据送达。所以真正的报文送达是epoll检测到的读写文件描述符的事件。所以accept只是负责把客户端信息封装后放到epoll监听队列内。注意,我们的定时器为了监测链接,所以是从accept的时候就把接收到的读写文件描述符的定时器设置好了。
  • CHECK_STATE_CONTENT这个主状态机的状态在咱们项目没用。这个只给POST请求用的。GET没请求体。没数据。

项目难点

  • 异步日志的加锁。粒度控制
  • 日志的拼接。
  • 生成响应体的时候计算偏移量

什么时候ET 什么时候LT

个人整理:

ET:连接数量较大的时候。假设5000个链接。我们为了不让第1个人速度很快然后队伍越往后延迟越高,(当然应该用消息队列)。我们可以轮询5000个链接,先每一个读1MB,然后循环。

LT:实时性较高,应该尽力处理完毕每一个链接及时返回。

客户端什么时候异常断开

客户端出BUG,段错误之类的,或者是接收到一半/发送到一半没信号了等等。

为什么用线程池

  • 避免创建和销毁线程的开销。(为每个线程的栈分配内存)
  • 削峰。如果没有线程池,那么大量链接涌入的时候服务器会同时开启大量的线程,会占用大量内存空间导致内存空间不足,影响服务器稳定性。而且会进行大量的线程切换,开销极大。如果线程数量是固定的,每个线程从队列中取出任务,这样大量涌入的时候不会影响服务器稳定性。而且线程切换是固定的,不会因为频繁新建和销毁线程导致切换不固定。

线程池中线程数量

一般是看是IO密集型还是CPU密集型。如果是IO密集型则大小是2N+1, CPU密集型是N+1。

  • IO密集型,大部分时间在处理I/O请求,不需要CPU提供多大算力,这时候频繁切换可以提高并发性,所以I/O密集型的处理,希望在高并发下进行,多线程并发消耗资源少。
  • CPU密集型,占用CPU算力大,希望能获得更长的时间轮片而不是经常切换;这样使用进程较好,进程本身优于线程,只是切换调度消耗的资源多。

如果请求过大,线程池处理不过来怎么办?

可以增加线程数量或使用集群。

tomcat的方式:

  • 判断如果当前线程数小于核心线程池,则新建一个线程来处理提交的任务
  • 如果当前当前线程池数大于核心线程池,小于最大线程数,则创建新的线程执行提交的任务
  • 如果当前线程数等于最大线程数,则将任务放入任务队列等待执行
  • 如果队列已满,则执行拒绝策略

什么时候用多进程什么时候用多线程

具体情况具体分析。比如游戏服务器需要用多进程,因为进程有隔离性,我们不希望一个线程挂掉影响整个进程。

对比维度多进程多线程总结
数据共享、同步数据共享复杂,需要用IPC;数据是分开的,同步简单因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂各有优势
内存、CPU占用内存多,切换复杂,CPU利用率低占用内存少,切换简单,CPU利用率高线程占优
创建销毁、切换创建销毁、切换复杂,速度慢创建销毁、切换简单,速度很快线程占优
编程、调试编程简单,调试简单编程复杂,调试复杂进程占优
可靠性进程间不会互相影响一个线程挂掉将导致整个进程挂掉进程占优
分布式适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单适应于多核分布式进程占

所以简单来说,比如我们的web server,是一个模拟Proactor模式,我们需要主线程把东西读进来放到队列然后子线程从队列拿东西。这种频繁的数据共享使用多线程会轻松很多。而且我们对于可靠性来说,单纯的进行请求的响应并不复杂,所以不太可能会导致线程挂掉。所以线程可能更好一点。

  • 使用多线程:任务之间独立,不需要数据交互的。需要强健壮性的。

服务器怎么知道断开连接了

  • 浏览器关闭了页面,浏览器会调用close。发送FIN
  • 服务器接收到了FIN包,TCP协议栈会把这个FIN包换成EOF结束符然后放到对应客户端读写描述符的接收缓冲区中。
    • 我们知道每一个socket都是文件描述符。
    • 我们知道每个socket都有一个自己的缓冲区。
  • 通过读取(可能是epoll,可能是read,可能是select等等)我们能读取到这个EOF,我们就知道了客户端想要关闭,不会再发送数据了。我们就可以这边准备进行关闭。
  • 我们读取到了EOF可以调用close进行关闭了。

注意我们这里HTTP开启了keep-alive,所以在http_connection文件里面的read函数,我们读取的时候不会走到bytes_read == 0。因为客户端没主动断开链接。除非我们把定时器检测去掉等着让他出来。

为什么socket有了内核缓冲区还需要一个用户缓冲区?

https://blog.csdn.net/farmwang/article/details/64450170

  • 假设应用程序需要发送40kB数据,但是操作系统的TCP发送缓冲区只有25kB剩余空间,那么剩下的15kB数据怎么办?如果等待OS缓冲区可用,会阻塞当前线程,因为不知道对方什么时候收到并读取数据。因此网络库应该把这15kB数据缓存起来,放到这个TCP连接的应用层发送缓冲区中,等socket变得可写的时候立刻发送数据,这样“发送”操作不会阻塞。如果应用程序随后又要发送50kB数据,而此时发送缓冲区中尚有未发送的数据(若干kB),那么网络库应该将这50kB数据追加到发送缓冲区的末尾,而不能立刻尝试write(),因为这样有可能打乱数据的顺序。

  • 假如一次读到的数据不够一个完整的数据包,那么这些已经读到的数据是不是应该先暂存在某个地方,等剩余的数据收到之后再一并处理

简而言之,就是,如果没有用户态的缓冲区,那么我们直接把数据放入内核缓冲区,会有非常多的自己判断的地方。所以我们设计出用户态的缓冲区,IO函数辅助我们进行读取和写入。换句话说,这个用户缓冲区是帮助我们对想要发送的信息和已经接收到的信息进行处理的地方。比如排序,等待。等等

我们的线程池+任务队列就是一种用户缓冲区设计。我们自己的readbuf和writebuf就是缓冲区

因为我们有了这个缓冲区,我们可以在读取的时候用循环读的方式等到读取完毕后再一起处理数据。如果没有这个缓冲区,我们直接从socket中读取的数据如果不够处理,那么我们就永远没法处理这个数据了。

写入也是,如果我们直接写入socket的缓冲区,那么我们会需要非常多的判断机制

主从状态机(HTTP解析)

主状态机从内部调用从状态机,从状态机驱动主状态机。每解析一部分就将状态改变,来完成状态机的解析跳转,最后得到一个完整的HTTP请求。

为什么用主从状态机

为了封装逻辑,使得代码逻辑清晰,编程效率高。

主状态机

CHECK_STATE_REQUESTLINE,解析请求行

  • 主状态机的初始状态,调用parse_line函数解析行数据
  • 调用process_read_line解析请求行获得请求方法、目标URL及HTTP版本号
  • 解析完成后主状态机的状态变为CHECK_STATE_HEADER

CHECK_STATE_HEADER,解析请求头

  • 调用process_read_headers函数解析请求头部信息
  • 判断是空行还是请求头,若是空行,进而判断content-length是否为0,如果不是0,表明是POST请求,则状态转移到CHECK_STATE_CONTENT,否则说明是GET请求,则报文解析结束。
  • 若解析的是请求头部字段,则主要分析connection字段,content-length字段,和host字段。其他字段可以直接跳过,各位也可以根据需求继续分析。

CHECK_STATE_CONTENT,解析消息体,仅用于解析POST请求

主状态机的逻辑

主状态机的函数是process_read

  • 这里的判断条件是
    • 主状态机转移到CHECK_STATE_CONTENT,该条件涉及解析消息体。(本项目没用到)
    • 从状态机转移到LINE_OK,该条件涉及解析请求行和请求头部
    • 两者为或关系,当条件为真则继续循环,否则退出
  • 首先我们调用过了parse_line。这个函数帮我们把\r\n换成了\0也就是字符串结束符。这样方便读取。主状态机初始状态是CHECK_STATE_REQUESTLINE,解析请求行。通过调用从状态机驱动主状态机。

  • 解析完请求行后,主状态机继续分析请求头。具体方法上文写了

从状态机

三种状态,标识解析一行的读取状态。

  • LINE_OK,完整读取一行
  • LINE_BAD,报文语法有误
  • LINE_OPEN,读取的行不完整

webbench原理

父进程fork若干个子进程,每个子进程在用户要求时间或默认的时间内对目标web循环发出实际访问请求,父子进程通过管道进行通信,子进程通过管道写端向父进程传递在若干次请求访问完毕后记录到的总信息,父进程通过管道读端读取子进程发来的相关信息,子进程在时间到后结束,父进程在所有子进程退出后统计并给用户显示最后的测试结果,然后退出。

mmap 零拷贝

传统方式进行数据读取和发送

整个过程发生了4次用户态和内核态的上下文切换4次拷贝,具体流程如下:

  1. 用户进程通过read()方法向操作系统发起调用,此时上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区
  3. CPU把读缓冲区数据拷贝到应用缓冲区,上下文从内核态转为用户态,read()返回
  4. 用户进程通过write()方法发起调用,上下文从用户态转为内核态
  5. CPU将应用缓冲区中数据拷贝到socket缓冲区
  6. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

QQ截图20220804011214

mmap零拷贝

零拷贝并非真的是完全没有数据拷贝的过程,只不过是减少用户态和内核态的切换次数以及CPU拷贝的次数

mmap+write简单来说就是使用mmap替换了read+write中的read操作,减少了一次CPU的拷贝。

mmap主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。

也就是说,使用了mmap之后,我们不再需要读了。我们拿到了mmap函数返回的指针(内存首地址)之后我们可以直接在这里修改或者是获取文件了。(mmap函数的形参有被映射文件的文件描述符)

在这里我们用mmap直接拿到了这个文件映射区的首地址,我们会直接把这个文件地址放到分散写的结构体内,然后用writev直接写入文件读写描述符(客户端文件描述符)即可。

所以整个过程发生了4次用户态和内核态的上下文切换3次拷贝,具体流程如下:

  1. 用户进程通过mmap()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区
  3. 上下文从内核态转为用户态,mmap调用返回
  4. 用户进程通过write()方法发起调用,上下文从用户态转为内核态
  5. CPU将读缓冲区中数据拷贝到socket缓冲区
  6. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

mmap的方式节省了一次CPU拷贝,同时由于用户进程中的内存是虚拟的,只是映射到内核的读缓冲区,所以可以节省一半的内存空间,比较适合大文件的传输。

QQ截图20220804011220

sendfile

sendfile简单说就是把write也拿掉了。类似于完美转发。数据不经过用户空间。

但这种操作仅适用于不需要用户空间读写的情况。比如我们这就不行,我们要写入额外的响应头。这种方法适合于静态文件服务器那种。放到这个项目来说,就是sendfile直接把我们的网站资源发出去了,但是我们的响应数据没办法加进去。

QQ截图20220804012822

进程间通信方式:

  1. 无名管道(pipe):管道允许一个进程和另一个与它有共同祖先的进程之间进行通信。
  2. 命名管道(FIFO):类似于管道,但是它可以用于任何两个进程之间通信,命名管道在文件系 统中有对应的文件名。命名管道通过命令mkfifo或系统调用mkfifo来创建。
  3. 信号(signal):信号是比较复杂的通信方式,用于通知接收进程有某种事情发生。除了用于 进程间通信外,进程还可以发送信号给进程本身;Linux除了支持UNIX早期信号语义函数signal 外,还支持语义符合POSIX.1标准的信号函数sigaction。(实际上,该函数是基于BSD的,BSD即 能实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数的功能)
  4. 内存映射(mapped memory):内存映射允许任何多个进程间通信,每一个使用该机制的进 程通过把一个共享的文件映射到自己的进程地址空间来实现它;
  5. 消息队列(message queue):消息队列是消息的连接表,包括POSIX消息队列和System V 消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消 息。消息队列克服了信号承载信息量少,管道只能成该无格式字节流以及缓冲区大小受限等缺点;
  6. 信号量(semaphore):信号量主要作为进程间以及同进程不同线程之间的同步手段。
  7. 共享内存 (shared memory):它使得多个进程可以访问同一块内存空间,是最快的可用IPC 形式。这是针对其他通信机制运行效率较低而设计的。它往往与其他通信机制,如信号量结合使用,以达到进程间的同步及互斥;
  8. 套接字(Socket):它是更为通用的进程间通信机制,可用于不同机器之间的进程间通信。起 初是由UNIX系统的BSD分支开发出来的,但现在一般可以移植到其他类UNIX系统上:Linux和 System V的变种都支持套接字。

进程间同步方式:

  • 信号量
  • 管程 https://www.cnblogs.com/Keeping-Fit/p/15064039.html 不是很懂
  • 互斥量(锁)(基于共享内存的快速用户态 )
  • 文件锁(通过 fcntl 设定,针对文件)

线程间同步方式:

  • 锁机制:(可以设置初始化条件让其可以在进程间使用)
    • 互斥锁
    • 条件变量
    • 读写锁
  • 信号量
  • 原子操作
  • 内存屏障
  • C++的future
  • Windos上的临界区
    • 这个linux没有,只能用互斥锁替代。
  • 临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在任意时刻只允许一个线程对共享资源进行访问,如果有多个线程试图访问公共资源,那么在有一个线程进入后,其他试图访问公共资源的线程将被阻塞,并一直等到进入临界区的线程离开,临界区在被释放后,其他线程才可以抢占。
    • 虽然临界区同步速度很快,但却只能用来同步本进程内的线程,而不可用来同步多个进程中的线程
  • 互斥量:采用互斥对象机制。 只有拥有互斥对象的线程才有访问公共资源的权限,因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享
  • 信号量:它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目
  • 事件: 通过通知操作的方式来保持线程的同步,还可以方便实现对多个线程的优先级比较的操作

临界区和互斥量的区别:

  • 临界区只能用于对象在同一进程里线程间的互斥访问;互斥体可以用于对象进程间或线程间的互斥访问。
  • 临界区是非内核对象,只在用户态进行锁操作,速度快;互斥体是内核对象,在核心态进行锁操作,速度慢。
  • 临界区和互斥体在Windows平台都下可用;Linux下只有互斥体可用。
  • 临界区: 通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。
  • 互斥量: 为协调共同对一个共享资源的单独访问而设计的

如何理解进程间通信,进程间同步和进程间互斥

  • 通信是多个进程访问同一个资源。
  • 同步是让他们按照顺序来。
  • 互斥是不能一起来。
  • 所以互斥是一种特殊的同步。

所以进程间的同步建立在访问上面。我如果没有访问同一个资源就没有同步的意义。同时我们也可以通过通信来告知其他线程同步和互斥的状态。所以进程间同步和进程间通信也可以算进程间通信。

所以说,我们可以理解为,我们想要通过7种进程间通信的方式去访问共享资源,比如多进程操作一个文件,操作一块共享内存。

但是我们需要他们按照顺序同步或互斥,我们就可以使用进程间同步的方法,比如互斥锁+信号量,互斥锁+共享内存,互斥锁+文件映射等等。

  • 我们也知道,进程间是独立的。所以我这个进程不可能被其他进程看见,那么我这个进程的东西(比如锁)更不可能被其他进程看见。所以我们如果加锁,一定是给共享资源加锁。同时为了让进程都可以访问锁,锁自己也必须可以在进程间访问。所以给共享资源加锁自然是要针把锁放到如共享内存,内存映射上。这样多个进程访问同一个资源的时候才可以找到锁,然后再进行下一步动作。
  • 那么线程呢?我们知道线程是可以访问全局资源的。(全局资源在数据段。数据段是共享的)。所以我们可以给线程本身上锁,也就是使用线程间同步的方式,比如条件变量之类的。

注意一下锁的含义。为什么锁叫互斥量?不要认为锁和资源是挨着的。也就是资源不一定和锁连着。

锁(互斥量)和资源是独立的。什么意思?假设我们有规定:想要打开抽屉,必须从桌子上拿走令牌。如果没有令牌则不能打开抽屉。所以说资源(抽屉)和锁(互斥量)不一定在一起。资源在抽屉里,锁在桌子上。两者是分离的。我只要确保每个人都可以访问到令牌(锁)和抽屉(资源)即可。

使用共享内存和互斥锁进行多进程通信的例子:

https://blog.csdn.net/qq_35382207/article/details/106627826

https://blog.csdn.net/weixin_44618297/article/details/124411195

共享内存 shmget 和 内存映射 mmap的区别

Linux提供了内存映射函数mmap, 它把文件内容映射到一段内存上(准确说是虚拟内存上,运行着进程), 通过对这段内存的读取和修改, 实现对文件的读取和修改。mmap()系统调用使得进程之间可以通过映射一个普通的文件实现共享内存。普通文件映射到进程地址空间后,进程可以像访问内存的方式对文件进行访问,不需要其他内核态的系统调用(read,write)去操作。用户对这段内存区域的修改可以直接反映到内核空间

这里是将设备或者硬盘存储的一块空间映射到物理内存,然后操作这块物理内存就是在操作实际的硬盘空间,不需要经过内核态传递。比如你的硬盘上有一个文件,你可以使用linux系统提供的mmap接口,将这个文件映射到进程一块虚拟地址空间,这块空间会对应一块物理内存,当你读写这块物理空间的时候,就是在读取实际的磁盘文件,就是这么直接高效。通常诸如共享库的加载都是通过内存映射的方式加载到物理内存的

mmap系统调用并不完全是为了共享内存来设计的,它本身提供了不同于一般对普通文件的访问的方式,进程可以像读写内存一样对普通文件进行操作(无需系统调用),IPC的共享内存是纯粹为了共享。

详细点说就是mmap()中没有进行数据拷贝,真正的数据拷贝是在缺页中断处理时进行的由于mmap()将文件直接映射到用户空间,所以中断处理函数根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了 一次数据拷贝 。因此,内存映射的效率要比read/write效率高。

  • mmap是在磁盘上建立(或打开已有的)一个文件,每个进程地址空间中开辟出一块空间进行映射(mmap每个进程都会有自己的内存映射区)。
    • 也就是说mmap操作的是文件
  • 而shm共享内存,每个进程最终会映射到同一块物理内存shm保存在物理内存,这样读写的速度肯定要比磁盘要快,但是存储量不是特别大。
    • 共享内存的方式原理就是将一份物理内存映射到不同进程各自的虚拟地址空间上,这样每个进程都可以读取同一份数据,从而实现进程通信。因为是通过内存操作实现通信,因此是一种最高效的数据交换方法。
    • 也就是说shm操作的是内存
  • 相对于shm来说,mmap更加简单,调用更加方便,所以这也是大家都喜欢用的原因。
  • 另外mmap有一个好处是当机器重启,因为mmap把文件保存在磁盘上,这个文件还保存了操作系统同步的映像,所以mmap不会丢失,但是shmget在内存里面就会丢失。
  • 总之,shm是在内存中创建空间,每个进程映射到此处。内存映射是创建一个文件,并且映射到每个进程开辟的空间中。
    • 由于mmap是文件,shm是内存,所以mmap比shm慢但是容量大。shm比mmap快但是容量小。

共享内存必须搭配锁来使用

  • 因为系统不会给共享内存提供互斥和同步。比如如果一个进程读的同时另一个进程在写,就会出现问题。
  • 那我们是给进程上锁还是内存上锁?
    • 进程是给共享资源(内存)上锁。
    • 线程可以给线程上锁。

一些EPOLL的细节

参考:https://mp.weixin.qq.com/s/QYxRwfe_OI9LTv5QqL7Exw

epoll在什么时候被触发?也就是协议栈什么时候触发回调函数来通知epoll有事件?

  1. 针对监听文件描述符,在三次握手完成之后,会往全连接队列中添加一个TCB线程控制块结点,然后触发一个回调函数,通知到epoll里面有个EPOLLIN事件。(三次握手完成后)

QQ截图20220901013121

  1. 针对读写文件描述符,客户端发送一个数据包,协议栈接收后回复ACK,之后触发一个回调函数,通知到epoll里面有个EPOLLIN事件(可读)(接收数据回复ACK后)

QQ截图20220901013152

  1. 每个连接的TCB里面都有一个sendbuf,在对端接收到数据并返回ACK以后,sendbuf就可以将这部分确认接收的数据清空,此时sendbuf里面就有剩余空间,此时触发一个回调函数,通知到epoll里面有个EPOLLOUT事件(可写)(发送数据收到ACK后)

QQ截图20220901013241

  1. 当对端发送close,在接收到fin后回复ACK,此时会调用回调函数,通知到epoll有个EPOLLIN事件(可读。客户端断开连接,服务端epoll会监听到EPOLLRDHUP)(接受FIN回复ACK后)

QQ截图20220901013351

  1. 当接收到rst标志位的时候,回复ack之后也会触发回调函数,通知epoll有一个EPOLLERR事件。(接收RST回复ACK后)

QQ截图20220901013356

EPOLL在哪里加锁了?

  • epoll_ctl() 对红黑树加锁。因为是对同一颗红黑树进行,增删改,这就涉及到资源竞争需要加锁了,此时我们对整棵树进行加锁
  • epoll_wait()对就绪队列加锁。因为操作的是就绪队列,所以需要对就绪队列进行加锁。
  • 回调函数() 对红黑树加锁,对就绪队列加锁

一般来说红黑树在节点多的时候用互斥锁。就绪队列用自旋锁。

nginx坑点:

默认是http1.0转发。结果我的服务器只支持1.1

默认轮询不知道为何不起作用,只有加权轮询才起作用。

调用服务器日志发现会同时给两台服务器都发送请求。

项目介绍

我实现的linux webserver有如下几个模块。

  • 核心部分:基于epoll的多路复用来进行连接的监听,读取和发送。
  • 任务队列模块搭配线程池:epoll把客户端发送的数据读取进来后,封装成对象送入任务队列,线程池内的子线程不断从任务队列中获取任务。
  • http解析:采用了主从状态机进行HTTP的解析和封装。封装完毕后通知主线程epoll任务可以发送了,主线程进行数据发送
  • 日志:同步和异步日志模块。异步日志也是有一个任务队列。单独开设一个日志线程。其他部分将日志内容送入任务队列,日志线程不断从任务队列中拿去日志然后写入文件。
  • 超时检测:基于链表的一个超时监测机制。主线程定时发送信号至管道,epoll也监听管道文件描述符。epoll监听到定时器信号后开始进行链表遍历,如果发现有链接一定时间没有操作,就关闭文件描述符释放资源。每次监听到文件描述符有操作后会更新绝对时间戳。也就是当前时间+3倍的timeslot。进行链接遍历的时候拿当前时间和这个时间比对,如果超时了就关闭连接。

更新

map是为了防止sql注入。

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