文章目录
并发模型
总体模式
程序使用Reactor模型,并使用多线程提高并发度。为避免线程频繁创建和销毁带来的开销,使用线程池,在程序的开始创建固定数量的线程。使用epoll作为IO多路复用的实现方式。
线程
一般而言,多线程服务器中的线程可分为以下几类:
- IO线程(负责网络IO)
- 计算线程(负责复杂计算)
- 三方库所用线程
本程序中的Log线程属于第三种,其它线程属于IO线程,因为Web静态服务器计算量较小,所以没有分配计算线程,减少跨线程分配的开销,让IO线程兼顾计算任务。除Log线程外,每个线程一个事件循环,遵循One loop per thread。
本程序使用的并发模型如下图所示:
MainReactor只有一个,负责响应client的连接请求,并建立连接,它使用一个NIO Selector。在建立连接后用Round Robin的方式分配给某个SubReactor,因为涉及到跨线程任务分配,需要加锁,这里的锁由某个特定线程中的loop创建,只会被该线程和主线程竞争。
SubReactor可以有一个或者多个,每个subReactor都会在一个独立线程中运行,并且维护一个独立的NIO Selector。
当主线程把新连接分配给了某个SubReactor,该线程此时可能正阻塞在多路选择器(epoll)的等待中,怎么得知新连接的到来呢?这里使用了eventfd进行异步唤醒,线程会从epoll_wait中醒来,得到活跃事件,进行处理。
eventfd 是一个比 pipe 更高效的线程间事件通知机制,一方面它比 pipe 少用一个 file descripor,节省了资源;另一方面,eventfd 的缓冲区管理也简单得多,全部“buffer” 只有定长8 bytes,不像 pipe 那样可能有不定长的真正 buffer。
我学习了muduo库中的runInLoop和queueInLoop的设计方法,这两个方法主要用来执行用户的某个回调函数,queueInLoop是跨进程调用的精髓所在,具有极大的灵活性,我们只需要绑定好回调函数就可以了,我仿照muduo实现了这一点。
one loop per thread
nginx基本原理介绍
1.在nginx启动后,会有一个master进程和多个worker进程,master进程主要用来管理worker进程
,包括:接受信号,将信号分发给worker进程,监听worker进程工作状态,当worker进程退出时(非正常),启动新的worker进程。基本的网络事件会交给worker进程处理。多个worker进程之间是对等的,他们同等竞争来自客户端的请求,各进程互相之间是独立的 。
一个请求,只可能在一个worker进程中处理,一个worker进程,不可能处理其它进程的请求。 worker进程的个数是可以设置的,一般我们会设置与机器cpu核数一致,这里面的原因与nginx的进程模型以及事件处理模型是分不开的 。
2.当master接收到重新加载的信号会怎么处理(./nginx -s reload)?,master会重新加载配置文件,然后启动新的进程,使用的新的worker进程来接受请求,并告诉老的worker进程他们可以退休了,老的worker进程将不会接受新的,老的worker进程处理完手中正在处理的请求就会退出。
3.worker进程是如何处理用户的请求呢?首先master会根据配置文件生成一个监听相应端口的socket,然后再faster出多个worker进程,这样每个worker就可以接受从socket过来的消息(其实这个时候应该是每一个worker都有一个socket,只是这些socket监听的地址是一样的)。当一个连接过来的时候,每一个worker都能接收到通知,但是只有一个worker能和这个连接建立关系,其他的worker都会连接失败,这就是所谓的惊群现在,为了解决这个问题,nginx提供一个共享锁accept_mutex,有了这个共享锁后,就会只有一个worker去接收这个连接。当一个worker进程在accept这个连接之后,就开始读取请求,解析请求,处理请求,产生数据后,再返回给客户端,最后才断开连接,这样一个完整的请求就是这样的了。
nginx的事件驱动机制
为什么几个worker进程(每一个worker进程里面其实只有一个主线程)能同时接收上万的请求呢?这是因为nginx事件处理机制是异步非阻塞的。nginx将一个请求划分为多个阶段来异步处理模块,每个阶段只是处理请求的一部分,如果请求的这一部分发生阻塞,nginx不会等待,它会处理其他的请求的某一部分。
传统web服务器的每个事件消费者独占一个进程(线程)资源,这种情况对于用户规模较小的情况来说,用户响应速度快,但是当并发规模达到数十万上百万的时候,由于线程(进程)数目过多,会频繁的切换,而且当线程阻塞的时候会进行睡眠,也会造成资源的浪费,这样服务器就会产生瓶颈.
nginx服务器采用的事件驱动机制不同,他不会为每个消费事件创建一个进程或线程,这样就不会产生由于进程间频繁切换占用cpu而产生的瓶颈,而且nginx不会让事件阻塞,即采用无阻塞事件驱动模型,这样就不会因为事件阻塞使进程睡眠而造成的资源浪费.
nginx将一个请求划分成多个阶段异步处理,每个阶段仅仅完成一个请求中的一部分,当本阶段任务完成后进入下一阶段.等待事件发生不是阻塞的等待,等待事件发生时候内。
状态机能干什么?
状态机主要的应用场景就是流程控制。一个状态机定义以后,在某个状态下就只接收固定的Event,也就是执行指定的操作,这样流程就能按照预期定义的那样流转,不会出现乱入的情况,执行了一些在某状态下不允许执行的操作。
一个很典型的应用就是工作流引擎:以工作流中典型的审批流程为例,审批流程按照预先定义的流程流转的固定的某些人手里,只有这一批固定的人才能审批,当审批后(可能是一个人审批,也可能是多个人审批)才会流转到下个节点,由下个节点的审批人继续审批,一直流转到最后一个节点。状态机的流转可以人工干预,也可以自动流转。定义为自动流转后,把业务流程定义完成后,只要添加一个定时任务,整个流程的运转就都由状态机来完成了。此外,当状态机加入了持久化操作后,所有的状态流转都会落地,当业务出现异常,方便定位问题,当流程定义的足够细粒度的话,还可以通过驱动状态机来实现重入,恢复异常的节点。
epoll工作模式
epoll的触发模式在这里我选择了ET模式,muduo使用的是LT,这两者IO处理上有很大的不同。ET模式要比LE复杂许多,它对用户提出了更高的要求,即每次读,必须读到不能再读(出现EAGAIN),每次写,写到不能再写(出现EAGAIN)。而LT则简单的多,可以选择也这样做,也可以为编程方便,比如每次只read一次(muduo就是这样做的,这样可以减少系统调用次数)。
muduo:epoll使用LT模式的原因
- 与poll兼容
- LT模式不会发生漏掉事件的BUG,但POLLOUT事件不能一开始就关注,否则会出现busy
loop,而应该在write无法完全写入内核缓冲区的时候才关注,将未写入内核缓冲区的数据添加到应用层output
buffer,直到应用层output buffer写完,停止关注POLLOUT事件 - 读写的时候不必等候EAGAIN,可以节省系统调用次数,降低延迟(注:如果用ET模式,读的时候读到EAGAIN(要把内核缓冲区读完才行,若只读一部分,即使今后数据再到来也不会给通知了,一直都处于高电平的状态,不会再触发了),写的时候直到output
buffer写完或者EAGAIN(要把内核缓冲区写满才行,若没有写满,下次再写入就不会再通知是否可写入了,ET模式只通知一次))
连接维护(针对非阻塞IO)
建立连接的过程 连接的建立比较简单,server端通过socket(),bind(),listen(),并使用epoll ET模式监听listenfd的读请求,当TCP连接完成3次握手后,会触发listenfd的读事件,应用程序调用accept(),会检查已完成的连接队列,如果队列里有连接,就返回这个连接,出错或连接为空时返回-1。此时,已经可以进行正常的读写操作了。 当然,因为是ET模式,accept()要一直循环到就绪连接为空。
分析 之所以说建立连接的过程比较简单,是因为数据的通信已经由操作系统帮我们完成了,这里的通信是指3次握手的过程,这个过程不需要应用程序参与,当应用程序感知到连接时,此时该连接已经完成了3次握手的过程,accept就好了。另一个原因是一般情况下,连接的建立都是client发起的,server端被动建立连接就好了,也不会出现同时建立的情况。
muduo的IO模型是IO复用,且fd是非阻塞模式,若使用阻塞模式,IO线程可能阻塞在read,write等系统调用,这样的话,其他fd的IO事件到来,IO线程也不能够及时处理,也就不能最大限度的使用IO线程
Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系统调用上,这样可以最大限度地复用 thread-of-control,让一个线程能服务于多个 socket连接。 IO 线程只能阻塞在 IO-multiplexing 函数上,如 select()/poll()/epoll_wait()。这样一来,应用层的缓冲是必须的,每个 TCP socket 都要有 stateful 的 input buffer和 output buffer。
应用层的缓冲是必须的,每个 TCP socket 都要有 stateful 的 input buffer和 output buffer:
Buffer 的要求
1:一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。另一方面,我们系统减少内存占用。如果有 10k 个连接,每个连接一建立就分配 64k 的读缓冲的话,将占用 640M 内存,而大多数时候这些缓冲区的使用率很低。
具体做法是,在栈上准备一个 65536 字节的 stackbuf,然后利用 readv() 来读取数据, iovec 有两块,第一块指向 muduo Buffer 中的 writable 字节,另一块指向栈上的 stackbuf。这样如果读入的数据不多,那么全部都读到 Buffer 中去了;如果长度超过 Buffer 的 writable 字节数,就会读到栈上的 stackbuf 里,然后程序再把stackbuf 里的数据 append 到 Buffer 中。
限制 假设server只监听一个端口,一个连接就是一个四元组(原ip,原port,对端ip, 对端port),那么理论上可以建立2^48个连接,可是,fd可没有这么多(操作系统限制、用户进程限制)。当连接满了,如果空等而不连接,那么就绪队列也满了后,会导致新连接无法建立。这里的做法我参考了muduo,准备一个空的文件描述符,accept()后直接close(),这样对端不会收到RST,至少可以知道服务器正在运行。
定时器
每个SubReactor持有一个定时器,用于处理超时请求和长时间不活跃的连接。muduo中介绍了时间轮的实现和用stl里set的实现,这里我的实现直接使用了stl里的priority_queue,底层是小根堆,并采用惰性删除的方式,时间的到来不会唤醒线程,而是每次循环的最后进行检查,如果超时了再删,因为这里对超时的要求并不会很高,如果此时线程忙,那么检查时间队列的间隔也会短,如果不忙,也给了超时请求更长的等待时间。
因为(1) 优先队列不支持随机访问
(2) 即使支持,随机删除某节点后破坏了堆的结构,需要重新更新堆结构。
所以对于被置为deleted的时间节点,会延迟到它(1)超时 或
(2)它前面的节点都被删除时,它才会被删除。
一个点被置为deleted,它最迟会在TIMER_TIME_OUT时间后被删除。
这样做有两个好处:
(1) 第一个好处是不需要遍历优先队列,省时。
(2)第二个好处是给超时时间一个容忍的时间,
就是设定的超时时间是删除的下限(并不是一到超时时间就立即删除),
如果监听的请求在超时后的下一次请求中又一次出现了,
就不用再重新申请RequestData节点了,
这样可以继续重复利用前面的RequestData,
减少了一次delete和一次new的时间。
核心结构
程序中的每一个类和结构体当然都必不可少,其中能体现并发模型和整体架构的,我认为是有两个:
Channel类:Channel是Reactor结构中的“事件”,它自始至终都属于一个EventLoop,负责一个文件描述符的IO事件,在Channel类中保存这IO事件的类型以及对应的回调函数,当IO事件发生时,最终会调用到Channel类中的回调函数。因此,程序中所有带有读写时间的对象都会和一个Channel关联,包括loop中的eventfd,listenfd,HttpData等。
EventLoop:One loop per thread意味着每个线程只能有一个EventLoop对象,EventLoop即是时间循环,每次从poller里拿活跃事件,并给到Channel里分发处理。EventLoop中的loop函数会在最底层(Thread)中被真正调用,开始无限的循环,直到某一轮的检查到退出状态后从底层一层一层的退出。
Log
Log的实现了学习了muduo,Log的实现分为前端和后端,前端往后端写,后端往磁盘写。为什么要这样区分前端和后端呢?因为只要涉及到IO,无论是网络IO还是磁盘IO,肯定是慢的,慢就会影响其它操作,必须让它快才行。
这里的Log前端是前面所述的IO线程,负责产生log,后端是Log线程,设计了多个缓冲区,负责收集前端产生的log,集中往磁盘写。这样,Log写到后端是没有障碍的,把慢的动作交给后端去做好了。
后端主要是由多个缓冲区构成的,集满了或者时间到了就向文件写一次。采用了muduo介绍了“双缓冲区”的思想,实际采用4个多的缓冲区(为什么说多呢?为什么4个可能不够用啊,要有备无患)。4个缓冲区分两组,每组的两个一个主要的,另一个防止第一个写满了没地方写,写满或者时间到了就和另外两个交换指针,然后把满的往文件里写。
与Log相关的类包括FileUtil、LogFile、AsyncLogging、LogStream、Logging。 其中前4个类每一个类都含有一个append函数,Log的设计也是主要围绕这个append函数展开的。
FileUtil是最底层的文件类,封装了Log文件的打开、写入并在类析构的时候关闭文件,底层使用了标准IO,该append函数直接向文件写。
LogFile进一步封装了FileUtil,并设置了一个循环次数,每过这么多次就flush一次。
AsyncLogging是核心,它负责启动一个log线程,专门用来将log写入LogFile,应用了“双缓冲技术”,其实有4个以上的缓冲区,但思想是一样的。AsyncLogging负责(定时到或被填满时)将缓冲区中的数据写入LogFile中。
LogStream主要用来格式化输出,重载了<<运算符,同时也有自己的一块缓冲区,这里缓冲区的存在是为了缓存一行,把多个<<的结果连成一块。
Logging是对外接口,Logging类内涵一个LogStream对象,主要是为了每次打log的时候在log之前和之后加上固定的格式化的信息,比如打log的行、文件名等信息。
对比muduo的日志库系统:
前端负责生成日志消息,后端则负责将日志消息写入本地日志文件
基本日志功能实现
日志库前端部分的调用关系为:Logger > Impl > LogStream > operator< g_output > AsyncLogging:: append。
LOG_宏会创建一个匿名Logger对象(其中包含一个Impl类型的成员变量,Impl对象构造时会添加时间戳信息),并调用stream()函数得到一个LogStream对象的引用,而LogStream重载了<<操作符,可以将日志信息写入LogStream的buffer中。这样LOG_语句执行结束时,匿名Logger对象被销毁,Logger的析构函数会在日志消息的末尾添加LOG_语句的位置信息(文件名和行号),最后调用output()函数将日志信息传输到后端,并由后端日志线程将日志消息写入本地日志文件。
强调一下,这里将Logger设置为匿名对象是一个非常重要的技巧,因为匿名对象是一使用完就马上销毁,而对于栈上的具名对象则是先创建的后销毁。也就是说,如果使用具名对象,则后创建的Logger对象会先于先创建的Logger对象销毁,这就会使得日志内容反序(更准确的说是一个作用域中的日志反序)。使用匿名Logger对象的效果就是:LOG_*这行代码不仅仅会生成日志,还会马上输出(并不一定会立即写到本地日志文件中,具体原因见多线程异步日志部分)。
#define LOG_INFO if (muduo::Logger::logLevel() <= muduo::Logger::INFO) \
muduo::Logger(__FILE__, __LINE__).stream()
LOG_INFO<<“info ...”; // 使用方式
muduo::Logger(__FILE__, __LINE__).stream()<<“info”;
Logger => Impl => LogStream => operator<< FixedBuffer => g_output => g_flush
多线程异步日志
即用一个背景线程负责收集日志消息并写入本地日志文件,其他线程只负责生成日志消息并将其传输到日志线程,这称为“异步日志”。
Muduo日志库采用了双缓冲技术,即预先设置两个buffer(currentBuffer_和nextBuffer_),前端负责往currentBuffer_中写入日志消息,后端负责将其写入日志文件中。具体来说,当currentBuffer_写满时,先将currentBuffer_中的日志消息存入buffers_,再交换currentBuffer_和nextBuffer_(std::move),这样前端就可以继续往currentBuffer_中写入新的日志消息,最后再调用notify_all()通知后端日志线程将日志消息写入本地日志文件。
使用两个buffer的好处是在新建日志消息时不必等待磁盘文件操作,也避免了每条新的日志消息都触发后端日志线程。换句话说,前端不是将一条条日志消息分别发送给后端,而是将多条日志消息合成为一个大的buffer再发送给后端,相当于批处理,这样就减少了日志线程被触发的次数,降低了开销。
日志文件rolling
以本地文件作为日志的destination,那么日志文件的滚动是必须的。Muduo库中日志文件滚动的条件有两个:其一是文件大小(每写满1GB新建一个日志文件),其二是时间(每隔一天新建一个日志文件)
# 涉及的知识点:
std::bind和std::function: 这两个是C++11的新特性,用于函数调用。
std::bind,这个可以绑定函数参数,返回一个可用的临时变量,用于延迟调用。
std::function,作用类似函数指针,但是它比函数指针要安全,无需释放,可以认为是函数指针的智能指针。
通常两者结合使用,通过bind绑定参数返回给function,在需要的时候调用function就好了。
#include <iostream>
#include <boost/function.hpp>
#include <boost/bind.hpp>
using namespace std;
class Foo
{
public:
void memberFunc(double d, int i, int j)
{
cout << d << endl;//打印0.5
cout << i << endl;//打印100
cout << j << endl;//打印10
}
};
int main()
{
Foo foo;
boost::function<void (int)> fp = boost::bind(&Foo::memberFunc, &foo, 0.5, _1, 10);
fp(100);//相当于(&foo)->memberFunc(0.5,100,10)
return 0;
}
std::move:也是C++11的东西,用于赋值
当你一个变量a要赋值给另一个变量b的时候,如果赋值完后变量a就要销毁,那么这一步赋值的代价就不划算,故提供了一种新的方式,权限转移机制,你也可以理解为让这个变量a变成临时变量,也就是左值变成了右值。通过这种操作赋值完后a的值会变得未知。这里又谈到了左值和右值的问题,那么我们下面谈谈右值引用。
右值引用&&:这个顾名思义就是引用右值的。还是C++11的特性。
先来说说左值和右值
在C++11中可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值)
举个例子a=3。a是变量可以出现在等号的左边和右边,所以是左值,3是常量只能出现在等号的右边是右值。
再有:a=a+b,a+b返回的是临时变量,只能出现在右边,故是右值。
然后右值引用就是用来处理无法引用右值的问题,在C++11以前,右值是没法引用的。
智能指针:虽然以前也有智能指针auto_ptr,但是到了C++11后已经摒弃这个东西了。
首先讲讲智能指针的原理,因为一般指针使用都是要new/malloc,然后离开作用域前要delete/free,那么你很有可能会忘了delete/free,而导致内存泄露。这时候智能指针就诞生了,通过把指针封装在模板类里面,调用构造函数分配内存,析构函数释放内存,那么只要离开作用域就会自动调用析构函数释放内存,就可以解决你忘了delete/free而导致的内存泄露问题了。其实本质上就是RAII机制.
shared_ptr:采用引用计数原理,每次复制都会把引用计数加1,销毁一个就减一,当引用计数为0时释放内存。
unique_ptr:控制权唯一,同一时间一个对象只能由一个unique_ptr指向它,离开作用域后释放内存,unique_ptr若要赋值给另一个unique_ptr,只能通过std::move变成右值,释放掉控制权才可以赋值给另一个unique_ptr。
weak_ptr:shared_ptr的辅助指针,只获得观测权,并不会使引用计数增加或减少。他的作用主要是解决循环引用问题,例如有两个类a和b,a的成员变量是shared_ptr <类b>,b的成员变量是shared_ptr<类a>,然后创建两个智能指针类,互相对成员变量赋值,最后导致a包含b,b包含a,那么他们的引用计数是2,当离开作用域后,假设b先销毁,那么b的引用计数减1,所以b的内存空间不释放,b的内存空间不释放意味着b里面的成员变量不释放,所以a的引用计数还是2,然后到a离开作用域,a也和b的结果一样,引用计数变成1,故两个都不能释放资源。为了解决这种循环引用问题,引入了weak_ptr来解决。
move函数的使用
https://blog.csdn.net/stary_yan/article/details/51284929
智能指针shared_ptr的源码剖析
shared_ptr源码
如图,shared_ptr类几乎什么都没有做,它是继承了__shared_ptr, __shared_ptr内部有一个类型为__shared_count类型的成员_M_refcount, __shared_count内部有类型为_Sp_counted_base*的_M_pi的成员, _Sp_counted_base才是整个shared_ptr功能的核心,通过_Sp_counted_base控制引用计数来管理托管的内存,由图可见_Sp_counted_base内部不持有托管内存的指针,这里__shared_count内部的成员其实是一个继承自_Sp_counted_base的_Sp_counted_ptr类型,_Sp_counted_ptr类型内部持有托管内存的指针_M_ptr, _M_pi是一个_Sp_counted_base基类对象指针,指向_Sp_counted_ptr子类对象内存,这样_M_pi内部就既可以控制引用计数,又可以在最后释放托管内存。
这里称_M_pi为管理对象,它内部的_M_ptr为托管对象,管理同一块托管对象的多个shared_ptr内部共用一个管理对象(_M_pi), 这里的多个shared_ptr可能是通过第一个shared_ptr拷贝或者移动而来, 管理对象内部有两个成员变量_M_use_count和_M_weak_count, _M_use_count表示托管对象的引用计数,控制托管对象什么时候析构和释放,大概就是有N个shared_ptr的拷贝那引用计数就是N,当引用计数为0时调用托管对象的析构函数且释放内存。_M_weak_count表示管理对象的引用计数,管理对象也是一个内存指针,这块指针是初始化第一个shared_ptr时new出来的,到最后也需要delete,所以使用_M_weak_count来控制管理对象什么时候析构,我们平时用到的weak_ptr内部其实持有的就是这个管理对象的指针,当weak_ptr拷贝时,管理对象的引用计数_M_weak_count就会增加,当_M_weak_count为0时,管理对象_M_pi就会析构且释放内存。
进程打开的文件描述符达到上限,服务器accept时,返回EMFILE
产生的问题:listenfd一直处于监听状态,而此时服务器无法接受客户端的连接,所以会一直触发,产生busy loop
处理方法:
一开始打开一个空闲的文件描述符;
当遇到上述情况时,先关闭这个文件描述符,此时将获得一个文件描述符的名额;
再用accept接受客户端连接;
立即关闭connectfd(优雅的与客户端断开连接);
重新打开一个文件描述符,以备再次出现上述情况
epoll的总结之四LT和ET使用EPOLLONESHOT
在前面说过,epoll有两种触发的方式即LT(水平触发)和ET(边缘触发)两种,在前者,只要存在着事件就会不断的触发,直到处理完成,而后者只触发一次相同事件或者说只在从非触发到触发两个状态转换的时候儿才触发。
这会出现下面一种情况,如果是多线程在处理,一个SOCKET事件到来,数据开始解析,这时候这个SOCKET又来了同样一个这样的事件,而你的数据解析尚未完成,那么程序会自动调度另外一个线程或者进程来处理新的事件,这造成一个很严重的问题,不同的线程或者进程在处理同一个SOCKET的事件,这会使程序的健壮性大降低而编程的复杂度大大增加!!即使在ET模式下也有可能出现这种情况!!
解决这种现象有两种方法,一种是在单独的线程或进程里解析数据,也就是说,接收数据的线程接收到数据后立刻将数据转移至另外的线程。第一种方法
第二种方法就是本文要提到的EPOLLONESHOT这种方法,可以在epoll上注册这个事件,注册这个事件后,如果在处理写成当前的SOCKET后不再重新注册相关事件,那么这个事件就不再响应了或者说触发了。要想重新注册事件则需要调用epoll_ctl重置文件描述符上的事件,这样前面的socket就不会出现竞态这样就可以通过手动的方式来保证同一SOCKET只能被一个线程处理,不会跨越多个线程。
TCP 网络编程的本质是处理三个半事件,即:
1. 连接的建立
2. 连接的断开:包括主动断开和被动断开
被动断开:
被动断开即客户端断开了连接,server 端需要感知到这个断开的过程,然后进行的相关的处理。
其中感知远程断开这一步是在 Tcp 连接的可读事件处理函数 handleRead 中进行的:当对 socket 进行 read 操作时,返回值为 0,则说明此时连接已断开。
接下来会做四件事情:
将该 TCP 连接对应的事件从 EventLoop 移除
调用用户的 ConnectionCallback
将对应的 TcpConnection 对象从 Server 移除。
close 对应的 fd。此步骤是在析构函数中自动触发的,当 TcpConnection 对象被移除后,引用计数为 0,对象析构时会调用 close。
3. 消息到达,文件描述符可读。
4. 消息发送完毕。这个算半个事件。
signalfd、timerfd、eventfd使用说明
signalfd:传统的处理信号的方式是注册信号处理函数;由于信号是异步发生的,要解决数据的并发访问,可重入问题。signalfd可以将信 号抽象为一个文件描述符,当有信号发生时可以对其read,这样可以将信号的监听放到select、poll、epoll等监听队列中。
int signalfd(int fd, const sigset_t *mask, int flags);
参数fd:如果是-1则表示新建一个,如果是一个已经存在的则表示修改signalfd所关联的信号;
参数mask:信号集合;
参数flag:内核版本2.6.27以后支持SFD_NONBLOCK、SFD_CLOEXEC;
成功返回文件描述符,返回的fd支持以下操作:read、select(poll、epoll)、close
timerfd:可以实现定时器的功能,将定时器抽象为文件描述符,当定时器到期时可以对其read,这样也可以放到监听队列的主循环中。
#include <sys/timerfd.h>
int timerfd_create(int clockid, int flags); //创建一个timerfd;返回的fd可以进行如下操作:read、select(poll、epoll)、close
int timerfd_settime(int fd, int flags,
const struct itimerspec *new_value,
struct itimerspec *old_value);
int timerfd_gettime(int fd, struct itimerspec *curr_value); //获取到期时间。
eventfd:实现了线程之间事件通知的方式,eventfd的缓冲区大小是sizeof(uint64_t);向其write可以递增这个计数 器,read操作可以读取,并进行清零;eventfd也可以放到监听队列中,当计数器不是0时,有可读事件发生,可以进行读取。
#include <sys/eventfd.h>
int eventfd(unsigned int initval, int flags);
//创建一个eventfd,这是一个计数器相关的fd,计数器不为零是有可读事件发生,read以后计数器清零,write递增计数器;返回的fd可以进行如下操作:read、write、select(poll、epoll)、close
三种新的fd都可以进行监听,当有事件触发时,有可读事件发生。
无锁队列的实现
开始说无锁队列之前,我们需要知道一个很重要的技术就是CAS操作——Compare & Set,现在几乎所有的CPU指令都支持CAS的原子操作
无锁队列的链表实现
初始化一个队列的代码很简,初始化一个dummy结点(注:在链表操作中,使用一个dummy结点,可以少掉很多边界条件的判断),如下所示:
InitQueue(Q)
{
node = new node()
node->next = NULL;
Q->head = Q->tail = node;
}
我们先来看一下进队列用CAS实现的方式,基本上来说就是链表的两步操作:
第一步,把tail指针的next指向要加入的结点。 tail->next = p;
第二步,把tail指针移到队尾。 tail = p;
EnQueue(Q, data) //进队列
{
//准备新加入的结点数据
n = new node();
n->value = data;
n->next = NULL;
//下面的p可以指向尾节点,也可以不指向尾节点
do {
p = Q->tail; //取链表尾指针的快照
} while( CAS(p->next, NULL, n) != TRUE);
//while条件注释:如果没有把结点链在尾指针上,再试
//若插入成功,Q->tail和p指针一定是相等的,置尾结点 Q->tail = n;
CAS(Q->tail, p, n);
}
说明:
(1)CAS是:原子比较与设置
if (p->next==null)
{
p->netx=n;//p如果指向的是尾节点,就将新节点添加到链表尾部
return TRUE;
}
else
return FALSE;
(2)但是你会看到,为什么我们的“置尾结点”的操作(第13行)不判断是否成功,因为:
如果有一个线程T1,它的while中的CAS如果成功的话,那么其它所有的/随后线程的CAS都会失败(因为其它线程插入的都不是尾节点),然后就会再循环;
此时,如果T1 线程还没有更新tail指针,其它的线程继续失败,因为tail->next不是NULL了;
直到T1线程更新完 tail 指针,于是其它的线程中的某个线程就可以得到新的 tail 指针,继续往下走了;
所以,只要线程能从 while 循环中退出来,意味着,它已经“独占”了,tail 指针必然可以被更新
DeQueue(Q) //出队列
{
do{
p = Q->head;
if (p->next == NULL){
return ERR_EMPTY_QUEUE;
}
while( CAS(Q->head, p, p->next) != TRUE );
return p->next->value;
}
服务器性能四大杀手:
数据拷贝 缓存
环境切换 (理性创建线程)该不该用多线程,单线程好还是多线程好,单核服务器(采用状态机编程, 效率最高)多线程能够充分发挥多核服务器的性能。
内存分配 增加内存池,减少向操作系统分配内存
锁竞争 尽可能减少锁的竞争。
EPOLL LT & ET
LT:电平触发
(1)EPOLLIN事件
什么时候触发呢?
内核中的socket接收缓冲区为空,可以理解为低电平,不会触发EPOLLIN事件;
内核中的socket接收缓冲区不为空,可以理解为高电平,即使缓冲区的数据没有读完,仍然会触发EPOLLIN事件,直到读完为止
(2)EPOLLOUT事件
内核中的socket发送缓冲区不满,可以理解为高电平
内核中的socket发送缓冲区满,可以理解为低电平
(3)LT是高电平触发
ET:边沿触发
低电平->高电平,会触发
高电平->低电平,会触发
LT:
epoll ET
epoll ET模式的EMFILE问题
线程标识符
- Linux中,每个进程有一个pid,类型pid_t,由getpid()取得。Linux下的POSIX线程也有一个id,类型
pthread_t,由pthread_self()取得,该id由线程库维护,其id空间是各个进程独立的(即不同进程中的线程可能有相同的id)。 - Linux中的POSIX线程库实现的线程其实也是一个进程(LWP),只是该进程与主进程(启动线程的进程)共享一些资源而已,比如代码段,数据段等。
- 有时候我们可能需要知道线程的真实pid。比如进程P1要向另外一个进程P2中的某个线程发送信号时,既不能使用P2的pid,更不能使用线程的pthread
id,而只能使用该线程的真实pid,称为tid。 - 有一个函数gettid()可以得到tid,但glibc并没有实现该函数,只能通过Linux的系统调用syscall来获取。
return syscall(SYS_gettid)
__thread,gcc内置的线程局部存储设施
(1)__thread只能修饰POD类型
POD类型(plain old data),与C兼容的原始数据,例如,结构和整型等C语言中的类型是 POD 类型(初始化只能是编译期常量),
但带有用户定义的构造函数或虚函数的类则不是
__thread string t_obj1(“cppcourse”); // 错误,不能调用对象的构造函数
__thread string* t_obj2 = new string; // 错误,初始化只能是编译期常量,指针类型是POD类型,但是new也是需要调用构造函数的,
不能是运行期的
__thread string* t_obj3 = NULL; // 正确
(2)非POD类型,也希望每个线程只有1份,该怎么办?
可以用线程特定数据tsd
父进程在创建了一个线程,并对mutex加锁,
父进程创建一个子进程,在子进程中调用doit,由于子进程会复制父进程的内存,这时候mutex处于锁的状态,
父进程在复制子进程的时候,只会复制当前线程的执行状态,其它线程不会复制。因此子进程会处于死锁的状态。
利用pthread_atfork解决
#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
调用fork时,内部创建子进程前在父进程中会调用prepare,内部创建子进程成功后,父进程会调用parent ,子进程会调用child
线程特定数据
在单线程程序中,我们经常要用到"全局变量"以实现多个函数间共享数据。
在多线程环境下,由于数据空间是共享的,因此全局变量也为所有线程所共有。
但有时应用程序设计中有必要提供线程私有的全局变量,仅在某个线程中有效,但却可以跨多个函数访问。
POSIX线程库通过维护一定的数据结构来解决这个问题,这个些数据称为(Thread-specific Data,或 TSD)。
线程特定数据也称为线程本地存储TLS(Thread-local storage)
对于POD类型的线程本地存储,可以用__thread关键字
线程特定数据的接口如下
pthread_key_create
一旦一个线程创建了一个key,其他线程都有这个key
pthread_key_delete
删除key,并不是删除数据;删除数据需要在pthread_key_create时,指定一个回调函数去销毁,因为
这个数据是堆上数据
pthread_getspecific获取
不同的线程可以通过这个key来访问这个数据
pthread_setspecific
为特定的线程指定特定的数据,这些实际数据就是线程所私有的
日志级别
TRACE
指出比DEBUG粒度更细的一些信息事件(开发过程中使用)
DEBUG
指出细粒度信息事件对调试应用程序是非常有帮助的。(开发过程中使用)
INFO
表明消息在粗粒度级别上突出强调应用程序的运行过程。
muduo库默认是INFO级别
WARN
系统能正常运行,但可能会出现潜在错误的情形。
ERROR
指出虽然发生错误事件,但仍然不影响系统的继续运行。
FATAL
指出每个严重的错误事件将会导致应用程序的退出。
Linux线程挂掉对整个进程的影响
严格的说没有“线程崩溃”,只是触发了SIGSEGV (Segmentation Violation/Fault)。如果没有设置对应的Signal Handler操作系统就自动终止进程(或者说默认的Signal Handler就是终止进程);如果设置了,理论上可以恢复进程状态继续跑(用longjmp之类的工具)
线程有自己的 stack,但是没有单独的 heap,也没有单独的 address space。只有进程有自己的 address space,而这个 space 中经过合法申请的部分叫做 process space。Process space 之外的地址都是非法地址。当一个线程向非法地址读取或者写入,无法确认这个操作是否会影响同一进程中的其它线程,所以只能是整个进程一起崩溃。
1.进程(主线程)创建了多个线程,多个子线程均拥有自己独立的栈空间(存储函数参数、局部变量等),但是多个子线程和主线程共享堆、全局变量等非栈内存。
2.如果子线程的崩溃是由于自己的一亩三分地引起的,那就不会对主线程和其他子线程产生影响,但是如果子线程的崩溃是因为对共享区域造成了破坏,那么大家就一起崩溃了。
3.举个栗子:主线程是一节车厢的乘务员,诸多乘客(也就是子线程)就是经过乘务员(主线程)检票确定可以进入车厢的,也就是主线程创建了诸多子线程,每个子线程有自己独立的区域(座位啊啥的),但是诸多乘客和乘务员共享走廊啊卫生间啊等等,如果其中一名乘客座位坏了,摔了(可以认为奔溃了),那么其他乘客和乘务员都不受影响,但是如果乘客将卫生间给破坏了,他也无法使用卫生间(崩溃了),其他乘客和乘务员也不能用卫生间,好吧,那么大家一起憋着吧(崩溃了)。
总体来说,线程没有独立的地址空间,如果崩溃,会发信号,如果没有错误处理的handler,OS一般直接杀死进程。就算是有handler了处理,一般也会导致程序崩溃,因为很有可能其他线程或者进程的数据被破坏了。
使用epoll时需要将socket设为非阻塞吗?
https://www.zhihu.com/question/23614342
https://blog.csdn.net/zxm342698145/article/details/80524331
只有边沿触发才必须设置为非阻塞。
边沿触发的问题:
1、sockfd 的边缘触发,高并发时,如果没有一次处理全部请求,则会出现客户端连接不上的问题。不需要讨论 sockfd 是否阻塞,因为epoll_wait() 返回的必定是已经就绪的连接,所以不管是阻塞还是非阻塞,accept() 都会立即返回。
2、阻塞 connfd 的边缘触发,如果不一次性读取一个事件上的数据,会干扰下一个事件,所以必须在读取数据的外部套一层循环,这样才能完整的处理数据。但是外层套循环之后会导致另外一个问题:处理完数据之后,程序会一直卡在 recv() 函数上,因为是阻塞 IO,如果没数据可读,它会一直等在那里,直到有数据可读。但是这个时候,如果用另一个客户端去连接服务器,服务器就不能受理这个新的客户端了。
3、非阻塞 connfd 的边缘触发,和阻塞版本一样,必须在读取数据的外部套一层循环,这样才能完整的处理数据。因为非阻塞 IO 如果没有数据可读时,会立即返回,并设置 errno。这里我们根据 EAGAIN 和 EWOULDBLOCK 来判断数据是否全部读取完毕了,如果读取完毕,就会正常退出循环了。
总结一下:
1、对于监听的 sockfd,最好使用水平触发模式,边缘触发模式会导致高并发情况下,有的客户端会连接不上。如果非要使用边缘触发,可以用 while 来循环 accept()。
2、对于读写的 connfd,水平触发模式下,阻塞和非阻塞效果都一样,建议设置非阻塞。
3、对于读写的 connfd,边缘触发模式下,必须使用非阻塞 IO,并要求一次性地完整读写全部数据。
ET模式下的accept问题
我们知道如果需要使用 IO 复用函数统一管理各个 fd,需要将 clientfd 设置成非阻塞的,那么 listenfd 一定要设置成非阻塞的吗?答案是不一定的——只要不用 IO 复用函数去管理 listenfd 就可以了,listenfd 如果不设置成非阻塞的,那么 accept 函数在没有新连接时就会阻塞。
条件变量和虚假唤醒(spurious wakeup)的讨论
对条件变量的使用包括两个动作:
- 线程等待某个条件, 条件为真则继续执行,条件为假则将自己挂起(避免busy wait,节省CPU资源);
- 线程执行某些处理之后,条件成立;则通知等待该条件的线程继续执行。
- 为了防止race-condition,条件变量总是和互斥锁变量mutex结合在一起使用。
var mutex;
var cond;
var something;
Thread1: (等待线程)
lock(mutex);
while( something not true ){
condition_wait( cond, mutex);
}
do(something);
unlock(mutex);
//============================
Thread2: (解锁线程)
do(something);
....
something = true;
unlock(mutex);
condition_signal(cond);
函数说明:
(1) Condition_wait():调用时当前线程立即进入睡眠状态,同时互斥变量mutex解锁(这两步操作是原子的,不可分割),以便其它线程能进入临界区修改变量。
(2) Condition_signal(): 线程调用此函数后,除了当前线程继续往下执行以外; 操作系统同时做如下动作:从condition_wait()中进入睡眠的线程中选一个线程唤醒, 同时被唤醒的线程试图锁(lock)住互斥量mutex, 当成功锁住后,线程就从condition_wait()中成功返回了。
虚假唤醒(spurious wakeup)
在采用条件等待时,我们使用的是
while(条件不满足){
condition_wait(cond, mutex);
}
而不是:
If( 条件不满足 ){
Condition_wait(cond,mutex);
}
这是因为可能会存在虚假唤醒”spurious wakeup”的情况。
也就是说,即使没有线程调用condition_signal, 原先调用condition_wait的函数也可能会返回。此时线程被唤醒了,但是条件并不满足,这个时候如果不对条件进行检查而往下执行,就可能会导致后续的处理出现错误。
虚假唤醒在linux的多处理器系统中/在程序接收到信号时可能回发生。在Windows系统和JAVA虚拟机上也存在。在系统设计时应该可以避免虚假唤醒,但是这会影响条件变量的执行效率,而既然通过while循环就能避免虚假唤醒造成的错误,因此程序的逻辑就变成了while循环的情况。
注意:即使是虚假唤醒的情况,线程也是在成功锁住mutex后才能从condition_wait()中返回。即使存在多个线程被虚假唤醒,但是也只能是一个线程一个线程的顺序执行,也即:lock(mutex) 检查/处理 condition_wai()或者unlock(mutex)来解锁.
解锁和等待转移(wait morphing)
解锁互斥量mutex和发出唤醒信号condition_signal是两个单独的操作,那么就存在一个顺序的问题。谁先随后可能会产生不同的结果。如下:
(1) 按照 unlock(mutex); condition_signal()顺序, 当等待的线程被唤醒时,因为mutex已经解锁,因此被唤醒的线程很容易就锁住了mutex然后从conditon_wait()中返回了。
unlock(mutex);
condition_signal(cond);
(2) 按照 condition_signal(); unlock(mutext)顺序,当等待线程被唤醒时,它试图锁住mutex,但是如果此时mutex还未解锁,则线程又进入睡眠,mutex成功解锁后,此线程在再次被唤醒并锁住mutex,从而从condition_wait()中返回。
condition_signal(cond);
unlock(mutex);
可以看到,按照(2)的顺序,对等待线程可能会发生2次的上下文切换,严重影响性能。因此在后来的实现中,对(2)的情况,如果线程被唤醒但是不能锁住mutex,则线程被转移(morphing)到互斥量mutex的等待队列中,避免了上下文的切换造成的开销。