
socket接受线程:C语言为了高并发所以选择了epoll。当程序启动的时候(g_net_updatec文件中main函数,会启动一个thread见函数create_accept_task)这个thread就处理一件事情,只管接收客户端的连接,当有连接进来的时候 通过epoll_ctl函数,把socket fd 加入到epoll里面去,epoll设置监听事件EPOLLIN | EPOLLET; 主要是监听的是加入到epoll中的socket是否可读(因为我的需求是客户端连上了server就会马上向server发送一份数据的)。其它的部分在主线程中处理。
主线程:是一个无线循环,epoll_wait 函数相当于把客户端的连接从epoll中拿出来(因为我们监听的是EPOLLIN | EPOLLET)说明这个时候客户端有数据发送过来)。再通过recv_buffer_from_fd 函数把客户端发送过来的数据读出来。然后其他的一切就抛给线程池去处理。
线程池:(代码中我会在池里面创建15个线程) 双向链表。加入线程就是在链表后面加一个链表项,链表的前面会一个一个被拿出来处理。主要是malloc 函数free函数,sem_wait函数sem_post的处理(sem_wait 会阻塞当值大于0是会减一,sem_post是值加一)。typedef void (FUNC)(void arg, int index);是我们自定义的线程的逻辑处理部分,arg是参数,index是第几个线程处理(我们隐形的给每个线程都标了号),例如代码中的respons_stb_info,更加具体可以看看代码里面是怎么实现的。聪明的你也可以改掉这块的内容改成动态线程池,当某个时刻的处理比较多的时候能够动态的增加线程,而不像我代码里面的是固定的。
数据库连接池:按照我的需求在处理客户端请求数据的时候是要访问数据库的。就是一下子创建出一堆的数据连接。要访问数据库的时候先去数据库连接池中找出空闲的连接,具体可以看下代码。使用的时候可以参考下database_processc文件(代码中数据库连接池和线程池中的个数是一样的)。这里我想说下get_db_connect_from_pool这个函数,我用了随机数,我是为了不想每次都从0开始去判断哪个连接没有用到。为了数据库连接池中的每个链接都能等概率的使用到,具体的还是可以看下代码的实现。
锁出现的原因
临界资源是什么: 多线程执行流所共享的资源
锁的作用是什么, 可以做原子 *** 作, 在多线程中针对临界资源的互斥访问 保证一个时刻只有一个线程可以持有锁对于临界资源做修改 *** 作
任何一个线程如果需要修改,向临界资源做写入 *** 作都必须持有锁,没有持有锁就不能对于临界资源做写入 *** 作
锁 : 保证同一时刻只能有一个线程对于临界资源做写入 *** 作 (锁地功能)
再一个直观地代码引出问题,再从指令集的角度去看问题
上述一个及其奇怪的结果,这个结果每一次运行都可能是不一样的,Why ? 按照我们本来的想法是每一个线程 + 20000000 结果肯定应该是60000000呀,可以就是达不到这个值
为何? (深入汇编指令来看) 一定将过程放置到汇编指令上去看就可以理解这个过程了
a++; 或者 a += 1; 这些 *** 作的汇编 *** 作是几个步骤
其实是三个步骤:
正常情况下,数据少, *** 作的线程少,问题倒是不大,想一想要是这样的情况下, *** 作次数大,对齐 *** 作的线程多,有些线程从中间切入进来了,在运算之后还没写回内存就另外一个线程切入进来同时对于之前的数据进行++ 再写回内存, 啥效果,多次++ *** 作之后结果确实一次加加 *** 作后的结果。 这样的 *** 作 (术语叫做函数的重入) 我觉得其实就是重入到了汇编指令中间了,还没将上一次运算的结果写回内存就重新对这个内存读取再运算写入,结果肯定和正常的逻辑后的结果不一样呀
来一幅解释一下
咋办 其实问题很清楚,我们只需要处理的是多条汇编指令不能让它中间被插入其他的线程运算 (要想自己在执行汇编指令的时候别人不插入进来) 将多条汇编指令绑定成为一条指令不就OK了嘛。
也就是原子 *** 作!!!
不会原子 *** 作? *** 作系统给咱提供了线程的 绑定方式工具呀:mutex 互斥锁(互斥量), 自旋锁(spinlock), 读写锁(readers-writer lock) 他们也称作悲观锁 作用都是一个样,将多个汇编指令锁成为一条原子 *** 作 (此处的汇编指令也相当于如下的临界资源)
悲观锁:锁如其名,每次都悲观地认为其他线程也会来修改数据,进行写入 *** 作,所以会在取数据前先加锁保护,当其他线程想要访问数据时,被阻塞挂起
乐观锁:每次取数据的时候,总是乐观地认为数据不会被其他线程修改,因此不上锁。但是在更新数据前, 会判断其他数据在更新前有没有对数据进行修改。
互斥锁
最为常见使用地锁就是互斥锁, 也称互斥量 mutex
特征,当其他线程持有互斥锁对临界资源做写入 *** 作地时候,当前线程只能挂起等待,让出CPU,存在线程间切换工作
解释一下存在线程间切换工作 : 当线程试图去获取锁对临界资源做写入 *** 作时候,如果锁被别的线程正在持有,该线程会保存上下文直接挂起,让出CPU,等到锁被释放出来再进行线程间切换,从新持有CPU执行写入 *** 作
互斥锁需要进行线程间切换,相比自旋锁而言性能会差上许多,因为自旋锁不会让出CPU, 也就不需要进行线程间切换的步骤,具体原理下一点详述
加互斥量(互斥锁)确实可以达到要求,但是会发现运行时间非常的长,因为线程间不断地切换也需要时间, 线程间切换的代价比较大
相关视频推荐
你绕不开的组件—锁,4个方面手撕锁的多种实现
“惊群”原理、锁的设计方案及绕不开的“死锁”问题
学习地址:C/C++Linux服务器开发/后台架构师零声教育-学习视频教程-腾讯课堂
需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括 C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg 等),免费分享
自旋锁
spinlock自旋锁
对比互斥量(互斥锁)而言,获取自旋锁不需要进行线程间切换,如果自旋锁正在被别的线程占用,该线程也不会放弃CPU进行挂起休眠,而是恰如其名的在哪里不断地循环地查看自旋锁保持者(持有者)是否将自旋锁资源释放出来 (自旋地原来就是如此)
口语解释自旋:持有自旋锁的线程不释放自旋锁,那也没有关系呀,我就在这里不断地一遍又一遍地查询自旋锁是否释放出来,一旦释放出来我立马就可以直接使用 (因为我并没有挂起等待,不需要像互斥锁还需要进行线程间切换,重新获取CPU,保存恢复上下文等等 *** 作)
哪正是因为上述这些特点,线程尝试获取自旋锁,获取不到不会采取休眠挂起地方式,而是原地自旋(一遍又一遍查询自旋锁是否可以获取)效率是远高于互斥锁了 那我们是不是所有情况都使用自旋锁就行了呢,互斥锁就可以放弃使用了吗
解释自旋锁地弊端:如果每一个线程都仅仅只是需要短时间获取这个锁,那我自旋占据CPU等待是没啥问题地。要是线程需要长时间地使用占据(锁)。。。 会造成过多地无端占据CPU资源,俗称站着茅坑不拉屎 但是要是仅仅是短时间地自旋,平衡CPU利用率 + 程序运行效率 (自旋锁确实是在有些时候更加合适)
自旋锁需要场景:内核可抢占或者SMP(多处理器)情况下才真正需求 (避免死锁陷入死循环,疯狂地自旋,比如递归获取自旋锁 你获取了还要获取,但是又没法释放)
自旋锁的使用函数其实和互斥锁几乎是一摸一样地,仅仅只是需要将所有的mutex换成spin即可
仅仅只是在init存在些许不同
何为惊群,池塘一堆, 我瞄准一条插过去,但是好似所有的都像是觉着自己正在被插一样的四处逃窜。 这个就是惊群的生活一点的理解
惊群现象其实一点也不少,比如说 accept pthread_cond_broadcast 还有多个线程共享epoll监视一个listenfd 然后此刻 listenfd 说来 SYN了,放在了SYN队列中,然后完成了三次握手放在了 accept队列中了, 现在问题是这个connect我应该交付给哪一个线程处理呢
多个epoll监视准备工作的线程 就是这群 (),然后connet就是鱼叉,这一叉下去肯定是所有的 epoll线程都会被惊醒 (多线程共享listenfd引发的epoll惊群)
同样如果将上述的多个线程换成多个进程共享监视 同一个 listenfd 就是(多进程的epoll惊群现象)
咱再画一个草图再来理解一下这个惊群:
如果是多进程道理是一样滴,仅仅只是将所有的线程换成进程就OK了
终是来到了今天的正题了: epoll惊群问题地解决上面了
首先 先说说accept的惊群问题,没想到吧accept 平时大家写它的多线程地时候,多个线程同时accept同一个listensock地时候也是会存在惊群问题地,但是accept地惊群问题已经被Linux内核处理了: 当有新的连接进入到accept队列的时候,内核唤醒且仅唤醒一个进程来处理
但是对于epoll的惊群问题,内核却没有直接进行处理。哪既然内核没有直接帮我们处理,我们应该如何针对这种现象做出一定的措施呢
惊群效应带来的弊端: 惊群现象会造成epoll的伪唤醒,本来epoll是阻塞挂起等待着地,这个时候因为挂起等待是不会占用CPU地。。。 但是一旦唤醒就会占用CPU去处理发生地IO事件, 但是其实是一个伪唤醒,这个就是对于线程或者进程的无效调度。然而进程或者线程地调取是需要花费代价地,需要上下文切换。需要进行进程(线程)间的不断切换 本来多核CPU是用来支持高并发地,但是现在却被用来无效地唤醒,对于多核CPU简直就是一种浪费 (浪费系统资源) 还会影响系统的性能
解决方式(一般是两种)
Nginx的解决方式:
加锁:惊群问题发生的前提是多个进程(线程)监听同一个套接字(listensock)上的事件,所以我们只让一个进程(线程)去处理监听套接字就可以了。
画两张图来理解一下:
上述还没有进行一个每一个进程都对应一个listensock 而是多线程共享一个listensock 运行结果如下
所有的线程同时被唤醒了,但是实际上会处理连接的仅仅只是一个线程,
咱仅仅只是将主线程做如上这样一个简单的修改,每一个线程对应一个listensock;每一个线程一个独有的监视窗口,将问题抛给内核去处理,让内核去负载均衡 : 结果如下
仅仅唤醒一个线程来进行处理连接,解决了惊群问题
本文通过介绍两种锁入手,以及为什么需要锁,锁本质就是为了保护,持有锁你就有权力有能力 *** 作写入一定的临界保护资源,没有锁你就不行需要等待,本质其实是将多条汇编指令绑定成原子 *** 作
然后介绍了惊群现象,通过一个巧妙地例子,扔一颗石子,只是瞄准一条鱼扔过去了,但是整池鱼都被惊醒了,
对应我们地实际问题就是, 多个线程或者进程共同监视同一个listensock。。。。然后IO连接事件到来地时候本来仅仅只是需要一个线程醒过来处理即可,但是却会使得所有地线程(进程)全部醒过来,造成不必要地进程线程间切换,多核CPU被浪费喔,系统资源被浪费
处理方式 一。 Nginx 源码加互斥锁处理。。 二。设置SO_REUSEPORT, 使得多个进程线程可以同时连接同一个port , 为每一个进程线程搞一个listensock 将问题抛给内核去处理,让他去负载均衡地仅仅将IO连接事件分配给一个进程或线程
#2022有你相伴#
了解IO多路复用应该对epoll和select不陌生吧。
首先,select是有缺陷的,就是当事件发生(调用select)的时候,都需要在用户态和内核态之间拷贝fd数组,要知道用户态和内核态之间进行内存的拷贝是非常昂贵的,如果有上万级别的并发网络需要处理的时候,服务器根本处理不来。
这时候,Linux内核的开发者应该算是简单又粗暴的增加了一个内核调用,就是epoll了,有时候简单粗暴的东西还是能提高效率的。
先来看select接口 :
select是用来等待fd状态的改变,核心就是定义一组fds,如果fds中的某一个fd的状态改变(比如变得可读、可写、或者异常等),select就会从等待中返回。
可以理解为这个东西必须要靠一个fd的改变才能让系统调用去等待,先别思维跳跃,我们一步一步的分析下去,它的手段我觉得肯定是让这个系统调用等在一个等待队列wait_queue上,在不需要执行任务的时候,我们就让任务进程休眠,直到条件改变时,我们再唤醒它。
通俗的说就是:你是餐饮店里唯一的一个的服务员,当店里没有顾客或者有顾客但是没有请求的时候,你处于空闲状态,就可以做点自己的事情(比如玩玩手机),当有顾客来有需求的时候你再过去服务。
如果店里来了10个顾客,有10个顾客(10个fd)都需要监控处理,哪个顾客有请求就要立即去处理,我们先抛开内核是怎么实现的,这时候能想到有两种办法:
招10个服务员对老板来说是需要成本的,所以创建10个线程也是需要成本的。
如果你有两个核,那么创建10个线程毫无意义,大家都知道线程是有时间片的,如果某一个fd的改变去处理只处理到一半,这时候这个线程的时间片用完了,就会切换到另一个线程执行,这个切换不仅增加了成本,而且毫无意义。
还不如只创建两个线程,每个线程只处理一组fds中的一半,处理完一个请求,再去处理另一个请求。不过如果是在用户态是做不了这件事的,只有调度器去搞定。这样你就只能等待在多个fd上,哪个fd请求,就去处理哪一个,处理完再去看看有没有下一个fd需要请求。
然而,如果随着fd的数量的不断增加,效率就会变得越来越低。
总之,对于select,应该没有什么好办法了,应该只能做到这样了,如果你觉得可能某一天,select实现了更高效的算法呢?
我觉得应该不会的,select接口已经那样了。我们只能接受select这个接口的缺陷,明明知道会带来限制,我们就知道去规避这个缺陷,知道什么情况下使用它。
再来看看epoll接口:
从接口看,和select接口几乎差不多的,区别主要是select主要是线性遍历fd数组去找就绪的fd,而epoll是把就绪的fd(epollfd)放在一个链表里,不需要遍历全部fd,这样就减少了不少开销。
我们来简单想一下:把原来select的大部分接口封装在epoll上,其实不是很难,epoll需要调用epoll_create创建epollfd,那么我们改成select自动创建epollfd,然后调用epoll_ctl把数组的fds设置进去,然后调用epoll_wait就可以了。
当然我只是简单想一下而已,初衷是想告诉大家:
再从内核的角度我们简单想一下:一开始应该会想到epoll和select应该是复用同一个内核的吧。实际上,它们都是独立的,一个在fs/selectc中实现,一个在fs/eventpollc中实现。
整体来看,select和epoll本质是一个东西,epoll有一个比较明显的改进是增加了两个对文件描述符的 *** 作的模式: 水平触发 (LT:level trigger)和 边缘触发 (ET:edge trigger)。
现在,对于select和epoll就会形成一种理解: epoll是对select的升级,在fds比较多的情况下,优先考虑使用epoll。
当我们分析epoll和select的时候,我们不能直接跳跃到内核看是怎么实现的,应该看它的整个逻辑来分析,脑子里要形成一些疑问,就比如select已经存在的缺陷是什么?但是又有什么好处?epoll为什么改进?改进了是不更好了?还有没有值得优化的地方?通过整个分析理解下来就能更加了解epoll和select。
你也可以继续阅读 点击 以下文章,下面是我推荐给大家的几篇文章:
1《竟然把通信协议讲的如此通俗?》
2《彻底明白Linux硬链接和软链接》
3《浅析Makefile、make、cmake》
4《常见硬件通信(SPI、I2C、CAN、USB、UART)协议介绍》
select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写 *** 作。
但select,poll,epoll本质上都是同步I/O ,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
I/O复用模型会用到select、poll、epoll函数:对一个IO端口,两次调用,两次返回,比阻塞IO并没有什么优越性。但关键是 能实现同时对多个IO端口进行监听。
这几个函数也会使进程阻塞,但是和阻塞I/O所不同的是,这几个函数 可以同时阻塞多个I/O *** 作。而且可以同时对多个读 *** 作,多个写 *** 作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O *** 作函数。
当一个客户端连接上服务器时,服务器就将其连接的fd加入fd_set集合,等到这个连接准备好读或写的时候,就通知程序进行IO *** 作,与客户端进行数据通信。大部分Unix/Linux 都支持 select 函数,该函数用于探测多个文件描述符的状态变化。
(1) 创建所关注的事件的描述符集合(fd_set),对于一个描述符,可以关注其上面的读(read)、写(write)、异常(exception)事件,所以通常,要创建三个fd_set,一个用来收集关注读事件的描述符,一个用来收集关注写事件的描述符,另外一个用来收集关注异常事件的描述符集合。
(2)调用select()等待事件发生。这里需要注意的一点是,select的阻塞与是否设置非阻塞I/O是没有关系的。
(3) 轮询所有fd_set中的每一个fd,检查是否有相应的事件发生,如果有,就进行处理。
优点:
相比其他模型,使用 select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。
缺点:
(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大!!!(复制大量句柄数据结构,产生巨大的开销 )。
(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大!!!(消耗大量时间去轮询各个句柄,才能发现哪些句柄发生了事件)。
(3)单个进程能够监视的文件描述符的数量存在最大限制,32位机默认是1024。
(4)select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO *** 作,那么之后每次select调用还是会将这些文件描述符通知进程。
(5)该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。
poll库是在linux2123中引入的,windows平台不支持poll。poll本质上和select没有太大区别,都是先创建一个关注事件的描述符的集合,然后再去等待这些事件发生,然后再轮询描述符集合,检查有没有事件发生,如果有,就进行处理。因此,poll有着与select相似的处理流程:
(1)select需要为读、写、异常事件分别创建一个描述符集合,最后轮询的时候,需要分别轮询这三个集合。而poll只需要一个集合,在每个描述符对应的结构上分别设置读、写、异常事件,最后轮询的时候,可以同时检查三种事件。
(2)它没有最大连接数的限制,原因是它是基于链表来存储的。
(1)大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
(2)poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
poll和select,它们的最大的问题就在于效率。它们的处理方式都是创建一个事件列表,然后把这个列表发给内核,返回的时候,再去轮询检查这个列表,这样在描述符比较多的应用中,效率就显得比较低下了。
epoll是一种比较好的做法,它把描述符列表交给内核,一旦有事件发生,内核把发生事件的描述符列表通知给进程,这样就避免了轮询整个描述符列表。
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll与select和poll的调用接口上的不同:select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。
(1)创建一个epoll描述符,调用epoll_create()来完成。epoll_create()有一个整型的参数size,用来告诉内核,要创建一个有size个描述符的事件列表(集合)。
(2)给描述符设置所关注的事件,并把它添加到内核的事件列表中。这里需要调用epoll_ctl()来完成。
(3)等待内核通知事件发生,得到发生事件的描述符的结构列表。该过程由epoll_wait()完成。得到事件列表后,就可以进行事件处理了。
(1)没有最大并发连接的限制,能打开FD的上限远大于1024(1G的内存上能监听约10万个端口);
(2)效率提升。不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
即epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。
(3)内存拷贝。epoll通过内核和用户空间共享一块内存来实现消息传递的。利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap 减少复制开销。epoll保证了每个fd在整个过程中只会拷贝一次(select,poll每次调用都要把fd集合从用户态往内核态拷贝一次)。
参考链接:
select、poll、epoll总结及ET、LT区别
欢迎分享,转载请注明来源:内存溢出
微信扫一扫
支付宝扫一扫
评论列表(0条)