目录
申明
1、笔记内容来自于哔哩哔哩上的视频大并发服务器开发、《Linux多线程服务端编程》那本书、网上的博客以及自己对muduo的理解。
2、目前学习了muduo的base库和net库的大部分源码 ,还有一小部分尚未阅读,待以后在进行二次阅读。
3、尚未阅读的muduo库部分源码包括http、inspect、protobuf、protorpc以及base库和net库中所带的test文件夹中的一些实例代码,还有muduo自带的examles。
第一部分 《Linux多线程服务端编程》所学总结
一、服务器开发的一些相关知识
1.1 服务器的相关知识
1、服务器的设计目标
高性能:能在规定的响应时间内完成客户端的请求。
高可用:能够保持服务器不间断服务,若某个服务器出现故障,也可转移到某个备用机进行服务。利用心跳
伸缩性:服务器的组件的既可以放到一台机器也可以放到两台机器上。
2、B/S架构也是一个特殊的C/S架构。
浏览器是http客户端,http服务器是服务端。
3、典型的服务器结构
网络I/O + 服务器高性能编程技术 + 数据库
4、降低数据库的压力的方法
1、队列 + 数据库连接池
2、尽可能将业务逻辑放到应用服务器这边,数据库上只做辅助的业务处理
3、建立缓存:先查缓存,若有则不需访问数据库。(主要是将查询到的数据进行缓存/热点数据存到缓存)
缓存的问题:缓冲的更新/同步
《1、缓存是有时效的,如果缓存失效(超时),那重新去数据库里查询,这种方法实时性差
《2、一旦数据库的数据更新了,立即通知前段的缓存更新,实时性高。
缓存换页:内存不够,将不活跃的数据换出内存。换页算法:(FIFO/LRU/LFU)
4、nosql有上面缓存所说的这些功能() 基于key/value存储,例如:redis、memcached 。
5、数据库读写分离:由于对数据库进行写时要加锁会导致对数据库的读操作也阻塞会降低效率
数据库的读操作 > 写操作
对数据库进行负载均衡:replication机制(主从数据库,读写分离)。
6、数据分区:(分库、分表)
分库:(水平分区)
分表:数据库可以按照一定的逻辑把表分散到不同的数据库(垂直分区)
4、应用服务器的负载均衡:
1、第一种方案:增加一个任务服务器来实现对其他应用服务器的监视,主要监视应用服务器的负载:
CPU高、IO高、并发高、内存换页高。应用服务器应提供一个接口供任务服务器来调用监 视,任务服务器通过这个接口来查询应用服务器的这些信息,查询到之后选取负载最低的 应用服务器来分配任务。
2、第二种方案:应用服务器空闲时主动从任务服务器上接受任务进行处理。这种方案是最科学的。
5、服务器性能四大杀手:
数据拷贝 缓存
环境切换 (理性创建线程)该不该用多线程,单线程好还是多线程好,单核服务器(采用状态机编程, 效率最高)多线程能够充分发挥多核服务器的性能。
内存分配 增加内存池,减少向操作系统分配内存
锁竞争 尽可能减少锁的竞争。
1.2 常见的并发网络服务程序设计方案
注:这里只是简单列出来,具体请去看《Linux多线程服务端编程》这本书的第六章的6.6.2。
1、iterative服务器(循环式服务器):整个程序是一个单线程的程序,只能使用短连接才能处理多个客户端链接。
不能充分利用多核CPU。
2、concurrent服务器(并发式服务器):多进程,多线程。是长连接。但是消耗资源。
3、prefork服务器(预先式服务器):预先创建多个进程或多个线程,容易触发惊群现象。
4、reactive服务器(反应式服务器):利用reactor实现。
5、reactor + thread per request(reactor + 并发式请求多线程)
6、reactor + workear thread(reactor + 一连接一线程)
7、reactor + threadpool(适应于计算密集型的任务)
8、multiple reactors(多个reactor,每一个reactor都是一个线程或进程,能适应更大的突发I/O)
reactors in threads(one loop per thread)
reactors in processes
9、multiple reactors + threadpool (one loop per thread + threadpool)(突发I/O与密集计算)
主从reactor
10、proactor服务器(proactor模式,基于异步I/O)
理论上proactor比reactor效率高一些。
异步I/O能够让I/O操作与计算重叠。充分利用DMA特性。
linux下最好的方式
multiple reactors + threadpool (one loop per thread + threadpool)(突发I/O与密集计算)
主从reactor
1.3 多线程服务器的一些问题
注:这里只是简单列出来,具体请去看《Linux多线程服务端编程》这本书的第三章的3.6。
1、linux下一个进程能同时启动多少个线程?
对于32位linux,一个进程的地址空间4G,其中用户态能访问3G左右,而一个线程的默认栈(stack)
大小是10M,所以一个进程大约最多能启动300个线程左右。
2、多线程能提高吞吐量吗?
对于计算密集型服务,不能。
如果要在一个8核的机器上压缩100个1G的文本文件,每个core的处理能力为200MB/S,那么“每次起
8个进程,一个进程压缩一个 文件”与“只启动一个进程(8个线程并发压缩一个文件)”,这两种
方式总耗时相当,但是第二种方式能较快的拿到第一个压缩完的文件。
所以多线程能提高响应速度但不能提高吞吐量。
3、线程池大小的选择
如果池中执行任务时,密集计算所占时间比重为P(0 < P <= 1),而系统一共有C个CPU,为了让C个
CPU跑满而不过载,线程池大小的经验公式 T=C/P, 即 T*P=C(让CPU刚好跑满)
假设C=8,P=1.0,线程池的任务完全密集计算,只要8个活动线程就能让CPU饱和。
假设C=8,P=0.5,线程池的任务有一半是计算,一半是IO,那么T=16,也就是16个“50%繁忙的
线程”能让8个CPU忙个不停。
4、线程分类
I/O线程(这里特指网络I/O)一个reactor一个线程
计算线程 CPU
第三方库所用线程,如lohhing,由比如database。
二、线程安全的对象生命周期
本文解决如下几个问题:
- 如何实现一个线程安全的容器,以及这个线程安全的容器什么时候是不安全的;
- 构造函数中,为保证线程安全禁止做哪些事情。
- 析构函数中不宜使用锁的原因。
- 使用指针时该如何判断指针是否还存活?
- 使用锁会降低程序的效率,使得并行的程序串行化,如何减少锁争用造成的延迟。
- shared_ptr的使用技巧与坑;
- 对象池中对象关系的探讨:如何降低对象之间的相互依赖。
- std::bind与std::function的简单理解。
1、什么是竞态条件?
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。
导致竞态条件发生的代码区称作临界区。
在临界区中使用适当的同步就可以避免竞态条件。
临界区实现方法有两种,一种是用synchronized,一种是用Lock显式锁实现。
实例:
class Counter {
protected long count = 0;
public void add(long value) {
this.count = this.count + value;
}
}
观察线程A和B交错执行会发生什么,两个线程分别加了2和3到count变量上,两个线程执行结束后count变量的值应该等于5。然而由于两个线程是交叉执行的,两个线程从内存中读出的初始值都是0。然后各自加了2和3,并分别写回内存。最终的值并不是期望的5,而是最后写回内存的那个线程的值,上面例子中最后写回内存的是线程A,但实际中也可能是线程B。如果没有采用合适的同步机制,线程间的交叉执行情况就无法预料。
add()方法就是一个临界区,它会产生竞态条件。
2.1 STL中的容器大部分都不是线程安全的,如何将其变为线程安全呢?
解决方案:使用锁保护数据成员
class Array {
private:
mutable std::mutex lock; //注意mutable关键字;
std::vector<int> data;
}
成员函数的实现使用锁保护data成员。
问题:C++中指针访问Array时是不能保证线程安全的。
理由:C++的析构函数的存在,使得其他线程delete掉Array* 时,其他线程还阻塞在lock中,析构函数完成,lock 就不存在了,阻塞在lock的线程就出现了未定义行为。
2.2 构造函数中,为保证线程安全禁止做哪些事情。
- 不能在构造函数中注册回调。
- 不要在构造函数中把this传递给跨线程对象;
- 即使在构造函数最后一行也不行。
解释:
- 在构造函数中注册回调的含义是:将自己的指针保存到容器Observable中,一旦有事件发生,就会调用自己的方法。
Foo(Observable* s) {
s->register(this);
}
this传递出去后,会导致有可能当前对象没有构造完成就调用了成员方法,未定义行为。
- 与上一点相同。感觉含义很类似。
- 最后一行也不行是因为,当前类可能是父类,子类的对象依然没有初始化完成,导致未定义行为。
2.3 析构函数中不宜使用锁的原因
(1)调用析构函数的时候,正常逻辑来说这个对象已经没有其他线程在使用了,用锁也没有效果;
(2)即使使用了锁,析构函数抢到了锁,其他线程还在等待这个锁,析构函数中锁被析构掉了,其他线程就是未 定义行为。
2.4 使用指针时该如何判断指针是否还存活?
例子:以观察者模式为例,observer对象注册自己到Observable,后者保存有前者的指针,一旦某个事件发生,Observable就通过observer指针调用其成员方法。多线程情况下,Observable无法得知当前调用的observer指针是否还有效,即使使用锁也不行。
方案:需要一种方法能告诉Observable,observer指针是否存活的方法。什么都不做是不可能实现的,需要额外一个变量来表示变量是否存活,可以理解为是指针通过一个代理来访问实际内存,代理掌握了实际内存是否被释放的消息。
实现:本质就是shared_ptr与weak_ptr的实现。如果使用weak_ptr保存指针,可以清楚的知道指针是否存活:如果weak_ptr可以转化为shared_ptr,证明指针还有效,否则无效。
weak_ptr不增加引用计数,weak_ptr对象只能由shared_ptr/weak_ptr赋值构造而来。
不打算决定对象的生死,就使用weak_ptr管理对象指针;否则使用shared_ptr
vector<weak_ptr<Observer>> x;
lock.lock();
for(auto xx : x) {
shared_ptr<Observer> obj(xx->lock());
if(obj) {
obj->update();
}
else {
x.erase(xx);
}
继续:即使能判断指针是否存活,即不会存在使用已经销毁或者正在销毁的指针了!但是不代表没有其他问题:
(1)锁争用造成的延时;
(2)死锁。
2.5 使用锁会降低程序的效率,使得并行的程序串行化,如何减少锁争用造成的延迟。
锁争用:访问需要加锁的数据成员的代码都需要加锁,使得比较简单的函数也需要等待较长时间。
解决1:解决锁争用的方法是:尽量减少临界区的大小;
解决方法1:local copy的方式。(适用于拷贝代价不大的对象)
读操作,临界区内拷贝出来,临界区外使用副本读取;
写操作,临界区外定义副本,完成要完成的操作,临界区内直接赋值或者swap;
写操作,临界区内拷贝出来,临界区外副本操作,临界区内swap;这样会有问题,可能会覆盖其他线程的修改。
写操作如果操作的是shared_ptr,可能会造成shared_ptr保存对象的析构操作(原来shared_ptr对象被赋值了,且引用计数为1),此时析构操作也是在临界区内。
写操作时析构移除临界区:临界区外定义副本,完成要完成的操作,临界区内swap;(此时析构移到了临时对象的身上)
2.6 shared_ptr的使用技巧与坑
坑:
- shared_ptr会延长对象的生命周期。
解释:某些函数实参采用非引用类型shared_ptr类型,调用这个函数的时候就会发生shared_ptr的拷贝操作,使得对象指针的引用计数值变大。如果这个函数返回一个对象,这个对象也中也存在这个shared_ptr的指针,那么shared_ptr对象的声明周期就被延长了。
例子:std::bind函数,基本作用是,为一个函数指针提供默认参数。其实参就会被拷贝一份出来。(模板参数,不论什么类型都会发生拷贝行为)
- shared_ptr的拷贝代价比指针要大。
解释:毕竟还要保存引用计数等变量,修改引用计数等行为。(建议使用引用传递) - 不能同时使用两个shared_ptr,容易引起误会;
类内(成员函数)使用shared_ptr与类外使用shared_ptr同时使用时,会造成析构两次的问题。
解释:类内部使用share_ptr的需求可以使用:shared_from_this() 代替this;
使用技巧:
- 作为函数参数时,建议使用const reference(常引用)传递;
- 在创建shared_ptr对象时,可以手动指定析构函数,这样可以保证可以跨线程来删除。
解释:windows下的进程会有好几个堆,每个线程都会有一个堆,一个堆里申请的需要在这个堆释放,所以存在跨模块释放的问题;shared_ptr通过指定析构函数,使得释放时,可以释放对应堆的对象。 - shared_ptr的析构如果可能发生在关键进程,可以用一个专门的线程来处理析构,使用BlockQueue<shared_ptr>来转移对象到析构线程;
- ower持有指向child的shared_ptr,child持有指向ower的weak_ptr;
解释:ower可以决定对象的生死,child只负责使用,不符合对象的生死。
2.7 对象池中对象关系的探讨:如何降低对象之间的相互依赖。
场景:A类中包含了B类对象,对象池的话就是A类中包含了很多B类对象。B类对象可以是暂存在A类中的,用于回调;也可能是被A类所使用。
(1) 需求:A类中的B类对象如果不使用了及时释放掉,以节省内存。
解决方案:不使用了的概念就是没有线程在使用了,可以使用指针来保存B类对象,shared_ptr来保存,使得引用计数为0时就释放掉。显然,使用shared_ptr的话,对象永远不会被释放掉。所以使用weak_ptr来保存。
class Item {};
class Factory {
private:
std::map<std::string, weak_ptr<Item>> data_;
mutable std::mutex lock_;
public:
shared_ptr<Item> get(const std::string& key); //使用shared_ptr作为返回值,因为出去使用的对象认为不能随便释放掉。
};
get方法的实现就相当较为简单了:
shared_ptr<Item> Factory::get(const string& key) {
shared_ptr<Item> ret;
lock_.lock();
auto itemptr = data_[key].lock(); //即使不存在,itemptr也是合理的weak_ptr
if(! itemptr) {
ret.reset(new Item());
data_[key] = ret; //第一,weak_ptr只能由shared_ptr/weak_ptr赋值而来。 //第二,两次查找map,效率不高,可以使用引用保存第一次查找的结果。
}
lock_.unlock();
return ret;
}
shared_ptr与weak_ptr使得对象可以被及时释放。
(2)需求:资源的及时释放,保存有weak_ptr的对象也要及时清理掉内存,如何处理。
创建了一个Item给外部使用,保存在data_中以便不要重复创建;但是外部用完了,对象就自动销毁了,但是Factory还保存着资源的weak_ptr,没有意义了。要清理掉。
即:对象的析构不仅仅需要释放自己,还要处理保存有自己weak_ptr的对象
解决方案:使用shared_ptr定制的析构函数来处理。
void deleteItem(Item* item, Factory* factory) {
factory->deleteItem(item->key()); //key是从Item类中获取。
delete item;
}
(3)问题:定制析构需要只能有一个参数,且参数应该是传到shared_ptr中的对象指针,那现在要处理factory,多了一个参数,要咋处理呢?
解决方案:std::bind函数缩减函数参数
auto deleter = std::bind(deleteItem, _1, this); //普通函数作为第一个参数,不需要使用&,静态成员函数与成员函数使用时需要。
这里的this只是一个例子,代表是Factory* 就可以了,因为前面shared_ptr在Factory中构造而来,所以使用this。
(4)指针是不能随便出现的,出现了就会存在内存释放问题,也存在指针是否是野指针的问题。上面的deleteItem函数不合理。
解决:由于在函数内部无法判断factory指向的对象是否还存在,所以不能直接调用。上面第四点说明了判断指针是否存在可以使用shared_ptr与weak_ptr来决定。
至于用哪个,得看Factory*是不是在这里必须存在,显然,这里只是清理Factory内部数据,如果Factory对象不在了,就不清理就好了。所以使用weak_ptr;
所以:
void deleteItem(Item* pItem, weak_ptr<Factory> pFactory) {
share_ptr<Factory> pFactoryShare = pFactory.lock();
if(pFactoryShare) {
pFactoryShare->deleteItem(pItem->getKey()); //pFactoryShare指向的对象一定存在。
}
delete pItem;
}
(5)如何将this变为shared_ptr或者weak_ptr,以方便在类成员函数内部调用shared_ptr为参数的函数?
上面第6点说明了,内部不可以直接使用shared_ptr,以防止外部也使用shared_ptr,造成两次析构的出现。
所以可以使用如下方式来实现:(思路固定。背下来就好)
class Factory : public std::enable_shared_from_this<Factory> { //必须继承这个类;
//类内部需要使用shared_ptr<this>的地方,使用shared_from_this()来代替。
//类内部需要使用weak_ptr<this>的地方,使用std::weak_ptr<Factory>(shared_from_this())就好了(就是使用shared_from_this()来生成了下weak_ptr())
};
通过相互使用weak_ptr,Item与Factory谁也不管谁,谁挂了也无所谓。
2.8 std::bind与std::function的简单理解。
使用起来比较简单,不再介绍,只介绍简单的理解。
std::function可以理解为可以统一函数格式,可以为函数重新命令。什么普通函数,类静态函数,类成员函数,经过function都变成了普通函数的格式,随便调用。(类成员函数的调用需要加上类对象指针)
std::bind不仅可以实现std::function函数的作用,还可以实现减少参数数量,添加默认参数等功能。
std::bind一个很常见的使用方式:为类静态函数和成员函数指定别名,简化类静态函数的调用方式。为成员函数提前加好类对象指针在第一个参数,后边再调用这个函数的时候,直接和调用类成员函数一样了。
2.9 小结
-
原始指针暴露给多个线程往往会造成竞态条件或额外的薄记负担。
-
统一使用shared_ptr/scoped_ptr来管理对象的生命周期,在多线程中尤为的重要。
-
shared_ptr是值语意,当心意外延长对象的生命周期 。例如 boost::bind和容器都可能拷贝shared_ptr。
-
weak_ptr是shared_ptr的好搭档,可以用作弱回调、对象池等。
-
保持开放心态,留意更好的解决方法,比如C++11引入的unique_ptr。忘记已被废弃的auto_ptr。
三、线程同步精要
线程同步的四项原则
1、首要原则是尽量最低限度的共享对象,减少需要同步的场合。
2、其次是使用高级构件,如:TaskQueue、Producer-Consumer Queue、CountDownLatch等等。
3、最后不得已必须使用底层同步原语时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量。
4、除了使用atomic整数外,不自己编写lock-free代码 ,也不要 用 ”内核级“ 同步原语。
本文解决如下问题:
- 使用锁时要注意哪些问题。
- 死锁常见的两个例子以及如何避免死锁的两个简单方法。
- 条件变量的使用注意问题。
- 单例模式的问题与写法。
- 条件变量与锁的使用场景;
- 条件变量中的虚假唤醒原理是什么?
- 如何避免把类当做函数调用这种问题?
- 如何减少锁争用?(锁的延迟的主要占用点)
3.1 使用锁时要注意哪些问题
- 不直接使用std::mutex的lock和unlock函数,一切交给unique_lock等封装对象来完成;
- 使用unique_lock对象时,要考虑当前调用栈上是否已经持有了这个锁,避免死锁。
- 不使用跨进程的mutex,进程间通信只使用TCP sockets;
- 加锁和解锁必须在同一线程;(RAII自动保证)
- 不使用递归锁和读写锁、信号量。
**对第五点的解释:**读写锁在读优先的情况下会阻塞写操作,甚至会造成饥饿现象;写优先的情况下,会阻塞读,对读效率很敏感的程序来说很不友好。而普通锁在设计良好的情况下,临界区很小,效率是很高的,没有这些问题。
信号量的实现完全可以通过互斥器与条件变量来实现。另外信号量有自己的计数值,通常我们的数据结构也会保存一个计数值,两个计数值需要保持一致,增加了出错的可能。
3.2 死锁常见的两个例子
1. 同一线程发生死锁
**同一个类中有锁的一个函数辗转调用了另一个有锁的函数。**func1和func2调用时都持有锁,然后func2调用了func3,func3反过来调用了func1。调用关系如图1所示,此时存在死锁现象。
图1
func2直接调用func1,没有func3也会发生死锁。
2. 两个线程发生死锁
**两个类中持有锁的两对函数相互调用。**类1中包含func1,func2;类2中包含func3,func4,然后func1调用func3,func4调用func2。(反向相互调用)如图2所示。
图2
func1和func2可以重叠;func3和func4可以重叠。
3. 死锁的检测与预防
预防:
- 严格控制锁的调用顺序,用锁之前需要想调用栈上都有了哪些锁;
- 将锁内调用的函数同时定义一个无锁版,锁内调用那个无锁版;
检测:死锁之后,打开core文件或者使用如下gdb命令查看:
thread apply all bt
看到线程阻塞在一个锁上,或者某个非条件变量或者epoll_wait函数上,就是发生了死锁。
3.3 条件变量的使用注意问题
条件变量一般用于等待某个条件成立,即等待某个bool表达式为真。
- wait端
(1)必须与mutex一起使用;wait函数和bool表达式的判断需要在mutex的保护下;
(2)把bool表达式的判断和wait放到while循环中。
std::unique_lock<std::mutex> lock(mtx);
while(queue.empty()) {
cond.wait();
}
// 在锁的保护下,从queue中获取变量。
// 为了防止条件变量被虚假唤醒
- signal端
(1)notify、notifyAll函数不一定在已上锁的情况下调用;
(2)调用notify之前一般要修改bool表达式;(修改bool表达式要有锁保护)
std::unique_lock<std::mutex> lock(mtx);
queue.push_back(x);
cond.notify();
条件变量是非常底层的同步原语,很少直接使用,一般 都是用它来实现高层的同步措施,如 BlockingQueue
或 CountDownLatch(倒计时)。
倒计时是一种常用且易用的同步手段,它主要有两种用途:
-
主线程发起多个子线程,等待这些子线程各自都完成 一定任务 之后,主线程才继续执行。通常用于主线程等待多个子线程完成初始化。
-
主线程发起多个子线程,子线程都等待主线程,主线程完成其他一些任务之后通知所有子线程开始执行。通常用于多个子线程等待主线程发出 “起跑” 命令。
倒计时的代码:
/*************************************************************************
> File Name: CountDownLatch.cpp
> Author: Nfh
> Mail: 1024222310@qq.com
> Created Time: 2020年07月07日 星期二 10时55分38秒
************************************************************************/
#include <iostream>
#include <mutex>
#include <condition_variable>
// 倒计时类
class CountDownLatch
{
public:
// 倒数几次
explicit CountDownLatch(int count) : count_(count){}
// 等待计数值变为0
void wait()
{
std::unique_lock<std::mutex> guard(mutex_);
while(count_ > 0)
condition_.wait(guard);
}
// 计数减一
void countDown()
{
std::lock_guard<std::mutex> guard(mutex_);
--count_;
if(count_ == 0)
condition_.notify_all();
}
private:
int count_;
mutable std::mutex mutex_;
std::condition_variable condition_;
};
int main()
{
return 0;
}
3.4 单例模式的问题与写法
1. double check locking(DCL)实现单例模式的问题
DCL实现方法:
if(instance == null) {
lock();
if(instance == null) {
instance = new Instance();
}
}
在对instance的初始化的时候,编译器会按如下步骤:
(1)分配内存空间;
(2)初始化内存;
(3)将内存的地址赋值给instance。
编译器对(2)(3)步没有顺序限制。结果就是如果先(3),就会instance没有初始化就有值了,外部线程到了第一个if,检测有值,就去使用了,发生了未定义行为。
2. 使用static来完成单例模式
class SingleInstance {
private:
public:
get() {
static instance;
return instance;
}
}
- 函数内的static对象是懒惰初始化,什么时候用什么时候初始化。
- 类静态变量的初始化则是饿汉初始化,即使不用类对象,也会在main函数开始执行之前进行初始化,不好。
- 除了使用函数内static初始化,还可以使用std::thread_once来保证只调用了一次。
3.5 条件变量与锁的使用场景
- 锁是为了访问共享数据;
- 条件变量是为了等待事件发生。等待事件发生严禁使用sleep函数。
- 条件变量的notify通常代表资源可用(生产者模式);notifyall通常代表状态变化。(比如倒计时系统,可以开始做事了的那种)
这两种用法都很常见。所以都要会。
3.6 条件变量中的虚假唤醒原理是什么?
1. 基本原因
虚假唤醒是因为条件变量的wait函数调用的是futex系统调用,这个系统调用在阻塞状态下,被信号打断的时候会返回-1。即线程被唤醒了。
2. 验证
将一个线程阻塞在cond.wait()函数上,然后调用kill向这个线程发送信号,然后就会发现阻塞的线程被唤醒了。
3. 解决
虚假唤醒代表的是等待条件变量唤醒的线程可能不是因为bool表达式为正而唤醒,所以需要将wait放到一个循环中,而不是一个if中。
C++11提供的条件变量的wait函数可以使用第二个参数来实现自动while,这样就不用放到while循环了。
3.7 如何避免把类当做函数调用这种问题
MutexLock、MutexLockGuard的实现
/*************************************************************************
> File Name: mutex.cpp
> Author: Nfh
> Mail: 1024222310@qq.com
> Created Time: 2020年07月07日 星期二 11时24分57秒
************************************************************************/
#include <iostream>
#include <pthread.h>
#include <assert.h>
namespace CurrentThread{
inline int tid()
{
return pthread_self();
}
}
class MutexLock
{
public:
MutexLock()
: holder_(0)
{
pthread_mutex_init(&mutex_, NULL);
}
~MutexLock()
{
assert(holder_ == 0);
pthread_mutex_destroy(&mutex_);
}
// 仅供MutexLockGuard 调用,严禁用户代码调用
void lock()
{
pthread_mutex_lock(&mutex_); // 这两行顺序不能反
holder_ = CurrentThread::tid();
}
void unlock()
{
holder_ = 0; // 这两行顺序不能反
pthread_mutex_unlock(&mutex_);
}
bool isLockedByThisThread()
{
return holder_ == CurrentThread::tid();
}
void assertLocked()
{
assert(isLockedByThisThread());
}
pthread_mutex_t* getPthreadMutex() // 仅供Condition调用,严禁用户代码调用
{
return &mutex_;
}
private:
pid_t holder_;
pthread_mutex_t mutex_;
};
class MutexLockGuard
{
public:
explicit MutexLockGuard(MutexLock& mutex)
: mutex_(mutex)
{
mutex_.lock();
}
~MutexLockGuard()
{
mutex_.unlock();
}
private:
MutexLock& mutex_;
};
#define MutexLockGuard(x) static_assert(false, "missing mutex guard var name")
int main()
{
return 0;
}
比如定义了GuardLock类来封装std::mutex的使用。但是使用时不小心这样使用了:
GuardLock(std::mutex);
问题:
定义了一个临时变量,紧接着就析构掉了。锁没有加上。
解决:
#define GuardLock(x) static_assert("false", "hehe");
编译期找到这个错误。
3.8 如何减少锁争用?(锁的延迟的主要占用点)
真正影响性能的不是锁,而是锁争用。
上一小节,有介绍如何减少锁争用:
- 对于拷贝代价比较小的共享变量来说:
读操作,临界区内拷贝出来,临界区外使用副本读取;
写操作,临界区外定义副本,完成要完成的操作,临界区内直接赋值或者swap;
读和写都使用副本,而不是使用共享变量,这样就不会出现竞争。当然,如果共享变量是指针,要拷贝的是指针指向的值。
问题:
多个进程的写操作还是会相互覆盖,且临界区定义副本,这个副本的初始值无法界定。所以这种方法不宜使用。(有缺点就不宜使用)
-
将无关操作移出临界区。
写操作时析构移除临界区:一般指的是shared_ptr在临界区内析构的问题
为了防止其在临界区内析构,我们可以使用栈上的临时变量来增加引用计数,然后在临界区外析构临时变量。
-
避免临界区内的循环。
避免出现一边遍历一边决定是否对共享变量进行更改的情况;应该在临界区外遍历,然后记住该改哪些元素,最后在临界区内统一修改。 -
shared_ptr来实现copy_on_write
有几个条件:(1)共享变量使用shared_ptr来管理。使用shared_ptr来管理其实和指针是一样的;
(2)读和写都要获取到锁,同一时间只能有一个线程拥有锁。copy_on_write的关键在于减少锁争用。
(3)读操作,放心读,抢到锁就读,但是需要在读之前定义一个临时变量,来增加引用计数;
(4)写操作,无法放心写,抢到锁之后,得先判断引用计数是否有别的线程在读,如果没有就可以直接写,如果有,为了减少写等待,需要深拷贝动作。
如图3所示:
读操作:
(1)抢到锁之后,先定义出来ptr1的副本ptr2,但是都是指向的共享变量,然后就可以退出临界区了。
(2)临界区外使用ptr2来访问临界区。因为ptr1可能被写。
(3)每个读都会创建一个ptr2副本,所以可能同一时间ptr的副本有超多个。
(4)可以在临界区外访问共享变量是因为只要有读,写不会改变这个共享变量,只会改变共享变量的副本。
写操作:
(1)抢到锁之后,判断有没有读(根据ptr的引用计数值是否为1);
(2)没有读的话,可以直接修改共享变量;
(3)有读的话,二话不说,把共享内存拷贝出来一份,然后在拷贝出来的内存中进行修改,最后把ptr3赋值给ptr。
(4)所有的写操作都要在临界区内部。
(5)如果这里的共享变量仍然是一个指针,拷贝共享变量使用深拷贝就可以。
问题:
- 由于存在拷贝操作,共享变量无法太大。
- 共享变量太大也可以,不要频繁写就可以。(比如一段时间更新一次数据)
- 如果 单线程写 && 写时获取到的是全部数据,(不是共享变量的一部分),则退化到第一点,无需拷贝,直接临界区内swap就可以了。
四、多线程服务器的适用场合与常用编程模型
4.1 单线程服务器常用的编程模型
1、非阻塞I/O + I/O复用(non-blockingIO + IO multiplexing)即Reactor模式。
2、Reactor模型的优点很明显,编程不难,效率也不错。不仅可以用于读写socket,连接的建立(connect(2) / accept(2) )甚至DNS解析都可以用非阻塞的方式进行,以提高并发度和吞吐量(throughput),对IO密集型 的应用是不错的选择 。
3、Reactor模式适合于IO密集型的应用。
4.2 多线程服务器的常用编程模型
1、one (event)loop per thread + threadpool(一个事件循环一个线程 + 线程池)
-
eventloop(也叫IO loop)用作IO multiplexing,配合non-blocking IO 和定时器。
-
threadpool 用来做计算,具体可以是任务队列或者生产者消费者队列。
2、这个方式的好处是:
-
线程的数目基本固定,可以在程序启动时设置,不会频繁创建和销毁。
-
可以很方便的在线程间调配负载。
-
IO事件发生的线程是固定的,同一个TCP连接不必考虑事件 并发。
3、大致介绍
-
Eventloop代表了线程的主循环,需要 让哪个线程干活,就把timer或 IO channel(如TCP连接)注册到哪个线程的loop里即可;
-
对实时性有要求的connection可以单独用一个线程;
-
数据量大的connection可以独占一个线程,并把数据处理任务分摊到另几个计算线程中(线程池);
-
其他次要的辅助性connections可以共享一个程序。
-
对于non-trivial的服务端程序,一般会采用non-blocking IO + IO multiplexing,每个 connection/acceptor都会注册到某个 eventloop上,程序里有多个eventloop,每个线程至多一个eventloop。
-
多线程程序对eventloop提出了更高的要求,那就是“线程安全”。要允许一个线程往别的线程的loop里塞东西,这个loop必须是线程安全的。
-
程序里有几个loop、线程池的大小等参数需要根据应用来设定,基本原则是 ”阻抗匹配“,使得CPU和IO都能高效的运作。
五、C++多线程编程精髓
5.1 学习多线程编程面临的思维转变有两点
1.当前线程可能随时会被切换出去,或者说被抢占了;
2.多线程程序中实践的发生顺序不再有全局统一的先后关系。
多线程程序的正确性不能依赖于任何一个线程的执行速度,不能通过原地等待(sleep())来假定其他线程的事件已经发生,而必须通过适当的同步来让当前线程能看到其他线程的事件的结果。
5.2 11个最基本的Pthreads函数是
2个:线程的创建和等待结束(join)。封装为muduo::Thread
4个:mutex的创建、销毁、等待、通知、广播。封装为muduo::MutexLock
5个:条件变量的创建、销毁、等待、通知、广播。封装为muduo::Condition
**用(thread、mutex、condition)可以完成任何多线程编程任务。当然我们一般也不会直接使用他们(mutex除外),而是使用更高级的封装,例如 mutex::ThreadPool 和 mutex::CountDownLatch等。**
5.3 C/C++系统库的线程安全性
C++的标准库容器和std::string都不是线程安全的,只有std::allocator保证是线程安全的。一方面的原因是为了避免不必要的性能开销,另一方面的原因是单个成员函数的线程安全并不具备可组合性。
例如:
safe_vector<int> vec; //全局可见
if(!vec.empty()) //没有加锁保护
{
int x = vec[0]; //这两步在多线程下是不安全的
}
5.4 linux上的线程标识
linux上的线程标识不适合使用pthread_t,原因很多,详情见书。取而代之,使用的是gettid(2)系统调用,其优势书中也写的很清楚。
为了避免效率问题,使用__thread关键字做了缓存,避免每次获取线程id时都需要执行一次系统调用。
这里需要注意 gettid() 的使用方法。
//gettid()这个函数不可以在程序中直接使用,它是linux本身的一个函数,直接使用会出现,尚未声明之类的错误。
//我们可以自已定义实现方法,如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/syscall.h> /*此头必须带上*/
pid_t gettid()
{
return syscall(SYS_gettid); /*这才是内涵*/
}
// 使用
void CurrentThread::cacheTid()
{
if (t_cachedTid == 0)
{
t_cachedTid = gettid();
int n = snprintf(t_tidString, sizeof t_tidString, "%5d ", t_cachedTid);
assert(n == 6); (void) n;
}
}
// 这样就可以了。。。。
5.5 线程的创建与销毁的守则
线程的创建和销毁需要遵循几条简单的原则:
1.程序库不应该在未提前告知的情况下创建自己的“背景线程”;
2.尽量用相同的方式创建线程,例如muduo::Thread;
3.在进入main()函数之前不用改启动线程;
4.程序中线程的创建最好能在初始化阶段全部完成。
5.6 线程销毁的集中方式
1.自然死亡。从线程主函数返回,线程正常退出。
2.非正常死亡。从线程主函数抛出异常或线程触发segfault信号等非操作;
3.在线程中调用pthread_exit()来立刻退出线程,
4.他杀。其他线程调用pthread_cancel()来强制终止某个线程。
5.7 多线程与IO
1、多个线程同时操作同一个socket文件描述符需要考虑的情况如下:
-
如果一个线程正在阻塞地read某个socket,而另一个线程close了此socket。
-
如果一个线程正在阻塞地accept某个listening socket,而另一个线程close了此socket;
-
一个线程正准备read某个socket,而另一个线程close了此socket,第三个线程又恰好open了另一个文件描述符,而fd号码刚好和之前的socket相同。
以上这几种情况都说明程序设计有问题。
2、 为了简单起见我认为多线程程序应该遵循的原则是:
- 每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了文件描述符的各种 race condition。
- 一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符。
- epoll也要遵循相同的原则 。为了稳妥起见,我们应该把同一个 epoll fd 的操作(添加、删除、修改、等待)都放到同一个线程中执行,这正是我们需要 muduo::Eventloop::wakeup()的原因。
3、以上的(2)这条规则有两个例外:
- 对于磁盘文件,在必要的时候多个线程可以同时调用pread(2)/pwrite(2) 来读写同一个文件;
- 对于UDP,由于协议本身保证消息的原子性,在适当的条件下(比如消息间彼此 相互独立)可以多个线程同时读写同一个UDP文件描述符。
5.8 RAII 的介绍
**RAII,也称为“资源获取就是初始化”,是C++等编程语言常用的管理资源、避免内存泄露的方法。**它保证在任何情况下,使用对象时先构造对象,最后析构对象。
1、基本介绍
RAII,也称为“资源获取就是初始化”,是C++语言的一种管理资源、避免泄漏的惯用法。C++标准保证任何情况下,已构造的对象最终会销毁,即它的析构函数最终会被调用。简单的说,RAII 的做法是使用一个对象,在其构造时获取资源,在对象生命期控制对资源的访问使之始终保持有效,最后在对象析构的时候释放资源。
2、分类
根据RAII对资源的所有权可分为常性类型和变性类型,代表者分别是boost::shared_ptr<> 和std::auto_ptr<> ;从所管资源的初始化位置上可分为外部初始化类型和内部初始化类型。
常性类型是指获取资源的地点是构造函数,释放点是析构函数,并且在这两点之间的一段时间里,任何对该RAII类型实例的操纵都不应该从它手里夺走资源的所有权。
变性类型是指可以中途被设置为接管另一个资源,或者干脆被置为不拥有任何资源。
外部初始化类型是指资源在外部被创建,并被传给RAII实例的构造函数,后者进而接管了其所有权。boost::shared_ptr<>和std::auto_ptr<>都是此类型。与之相对的是内部初始化类型。
其中,常性且内部初始化的类型是最为纯粹的RAII形式,最容易理解,最容易编码。
3、实际应用
每当处理需要配对的获取/释放函数调用的资源时,都应该将资源封装在一个对象中,实现自动资源释放。例如,我们无需直接调用一对非成员函数OpenPort/ClosePort,而是可以考虑定义常性且内部初始化的RAII概念的“端口”操作类:
class Port{
public:
Port(const string& destination);//调用OpenPort
~Port();//调用ClosePort
};
void DoSomething(){
Port port1(“server1:80”);
…
}
shared_ptr<Port> port2 = /…/; //port2在最后一个引用它的
//shared_ptr离开作用域后关闭
通过使用上述RAII类型,可以避免程序员忘记关闭端口而引起的泄漏,还可以确保异常发生时栈展开过程中自动释放端口资源。
4、与STL容器
STL容器是基于值语义的,在容器内部,对象是常被复制的。如果RAII类型需要存入STL容器,需要作一些处理。
class Resource
{
public:
Resource() {/*分配资源*/}
~ Resource() {/*释放资源*/}
private:
int handle;
};
std::map< Identifier, Resource > resourceMap;
// 以上代码中STL容器对Resource的复制将导致运行期错误。最好的方法是让RAII类型继承于
// boost::noncopyable[2],而后在容器中使用引用计数的指针:
class Resource : public boost::noncopyable
{
public:
Resource() {/*分配资源*/}
~ Resource() {*/释放资源*/}
private:
int handle;
};
typedef boost::shared_ptr<Resource> PointerToResourceType;
typedef std::map< Identifier, PointerToResourceType> ResourceMapType;
ResourceMapType resourceMap;
//作为替代,还可以使用非拷贝行为的容器:boost::ptr_map<Identifier,Resource> map;
5.9 多线程与fork()
fork()之后,子进程不能调用:
1.malloc()
2.任何可能分配或释放内存的函数,包括new、map::insert()、snprintf
3.任何Pthread函数
4.printf()系列函数
5.除了明确列出的“signal安全”函数之外的任何函数
5.10 多线程与signal
在多线程程序中,使用signal的第一原则是不要使用signal:
1.不要用signal作为IPC的手段;
2.也不要使用基于signal实现的定时函数;
3.不主动处理各种异常信号;
4.在没有别的替代方法的情况下,把异步信号转换为同步的文件描述符事件。
5.11 小结
- 线程是宝贵的,一台机器上不应该同时运行几百个、几千个用户线程,这会大大增加内核scheduler的负担;降低整体的性能。
- 线程的创建和销毁是有代价的,一个程序最好在一开始创建所需的线程,并一直反复使用;
- 每个线程应该有明确的职责,例如IO线程(运行EventLoop::loop(),处理IO事件)、计算线程(位于 ThreadPool中,负责计算 );
- 线程之间的交互应尽量简单;理想情况下线程之间只用于消息传递 (例如BlockingQueue)方式交互。如果必须 用锁,那么最好避免一个线程同时持有 两把或者更多的锁,这样可彻底防止死锁。
- 要预先考虑清楚一个mutable shared对象将会暴露给哪些线程,每个线程是读还是写,读写有无可能并发进行。
1、为什么要用多线程来做服务器?
-
当前的机器的单核的红利已经结束。
-
多线程拥有自身优势。
-
API日益成熟,操作系统和标准库都已经支持多线程。
2、要在数据计算和数据I/O之间找到平衡点。
3、多线程编程可能的问题包括:
-
死锁
-
乱序
-
并发访问数据造成的问题
-
低效率
4、C++11带来的新概念
-
高阶接口:(async future)
-
低阶接口:(thread mutex)
如果没有必要的话,线程间不要共享资源。
5、
// 对于cout的输出方法来说不是线程安全的
std::cout << i << "info\n";
// 以上的操作相当于调用了:std::cout.operator<<(i); 和 std::cout.operator("info")
// 所以不是线程安全的
// 对于C语言的printf函数它是线程安全的
printf("%d info\n", i);
六、高效的多线程日志
注:这里只是一些简单总结,具体细节去看《Linux多线程服务端编程》第五章— 高效的多线程日志
6.1 日志的分类和作用
1、两种日志:
- 交易日志
- 诊断日志
2、日志作用
-
开发过程中:
- 调试错误
- 更好的理解程序
-
运行过程中:
- 诊断系统故障并处理
- 记录系统运行状态
6.2 关键进程记录的日志
1、日志通常需要记录:
- 收到的每条内部消息的 ID、关键字段、长度、hash 值等。
- 收到的每条外部消息的全文。
- 发送消息的全文,每条消息都有全局唯一的id
- 关键内部状态的变更
- 另外:
- 每条日志都有时间戳
- 一个日志库大致分为:前端 - 生成日志;后端 - 把日志写到目的地。异步日志
6.3 日志库应该提供的功能
1、日志的级别
TRACE /调试/
- 指出比DEBUG粒度更细的一些信息事件(开发过程中使用)
DEBUG /调试/
- 指出细粒度信息事件对调试应用程序是非常有帮助的。(开发过程中使用)
INFO /信息/
- 表明消息在粗粒度级别上突出强调应用程序的运行过程。
WARN /警告/
- 系统能正常运行,但可能会出现潜在错误的情形。
ERROR /错误/
- 指出虽然发生错误事件,但仍然不影响系统的继续运行。
FATAL
- 指出每个严重的错误事件将会导致应用程序的退出。
2、日志消息可能有多个目的地,如文件,socket,SMTP。对于分布式系统服务进程而言,目的地只有本地文件
3、日志滚动
- 文件大小(例如每写满1G换下一个文件)
- 时间(每天零点新建一个日志文件,不论前一个文件是否写满)
4、日志消息可以配置
5、可以设置运行时过哭泣
6.4 日志消息的几个要点
- 尽量每条 日志占一行,方便分析
- 时间戳精确到微妙,gettimieofday()
- 使用GMT时区
- 打印线程id
- 打印日记级别
- 打印原文件名和行号
6.5 多线程异步日志
1、线程安全的多线程日志的解决思路
1、用一个全局锁保护IO,或者每个线程单独写一个日志文件。性能堪忧,前者造成所有线程抢占一个锁,后者会让业务线程阻塞在写磁盘操作上
2、每个进程只写一个日志文件,用一个背景线程负责收集日志消息,并写入日志文件,其他业务线程只需往这个日志线程中发送日志消息,称为“异步日志”
2、我们需要一个“队列”将日志前端的数据传送到后端(日志线程)
3、muduo日志库采用的是双缓冲技术
基本思路是准备两块 buffer:A 和 B,前端负责往 buffer A 填写数据,后端负责将 buffer B 的数据写入文件。buffer A 写满就交换 A 和 B。如此往复。
4、日志消息堆积
前端陷入死循环,拼命发送日志消息,超过后端的处理能力
muduo日志处理日志堆积的方法:直接丢到多余的buffer以腾出内存
if (buffersToWrite.size() > 25)
{
char buf[256];
snprintf(buf, sizeof buf, "Dropped log messages at %s, %zd larger buffers\n",
Timestamp::now().toFormattedString().c_str(),
buffersToWrite.size()-2);
fputs(buf, stderr);
output.append(buf, static_cast<int>(strlen(buf)));
buffersToWrite.erase(buffersToWrite.begin()+2, buffersToWrite.end());
}
6.6 日志的其他方案
- 使用队列前后端中传递消息,每一个消息都是一个std::string,这种消息都要分配内存,并由后端线程释放
- muduo现在的异步日志实现用了一个全局锁。尽馆临界区很少,但是如果线程数目较多,锁的争用也可能影响性能
6.7 学习muduo日志库一些必要知识
1、预定义宏
/*************************************************************************
> File Name: ydh.c
> Author: Nfh
> Mail: 1024222310@qq.com
> Created Time: Sat 27 Jun 2020 09:36:19 AM CST
************************************************************************/
/*
*__DATE__ 进行预处理的日期("Mmm dd yyyy")
*__FILE__ 代表当前源代码文件名的字符串文字
*__BASE_FILE__ 获取正在编译的源文件名
*__LINE__ 代表当前源代码文件中的行号的整数常量
*__TIME__ 源代码编译时间,格式为 "hh:mm:ss"
*__STDC__ 设置为1时,表示该实现遵循C标准
*__STDC_HOSTED__ 为本机环境设置为1,否则设置为0
*__STDC_VERSION__ 为C99时设置为199901L
*__FUNCTION__或者__func__获取所在的函数名(预定义标识符而非预定义宏)
*
* */
#include<stdio.h>
int main()
{
printf("date: %s\n", __DATE__);
printf("file: %s\n", __FILE__);
printf("base_file: %s\n", __BASE_FILE__);
printf("line: %d\n", __LINE__);
printf("time: %s\n", __TIME__);
printf("function: %s\n", __FUNCTION__);
printf("func: %s\n", __func__);
return 0;
}
2、 intptr_t、uintptr_t数据类型的解析
这两个数据类型是ISO C99定义的,具体代码在linux平台的/usr/include/stdint.h头文件中。
该头文件中定义intptr_t和uintptr_t这两个数据类型的代码片段如下:
/* Types for `void *' pointers. */
#if __WORDSIZE == 64
# ifndef __intptr_t_defined
typedef long int intptr_t;
# define __intptr_t_defined
# endif
typedef unsigned long int uintptr_t;
#else
# ifndef __intptr_t_defined
typedef int intptr_t;
# define __intptr_t_defined
# endif
typedef unsigned int uintptr_t;
#endif
-
在64位的机器上,intptr_t和uintptr_t分别是long int、unsigned long int的别名;
-
在32位的机器上,intptr_t和uintptr_t分别是int、unsigned int的别名。
那么为什么要用typedef定义新的别名呢?我想主要是为了提高程序的可移植性(在32位和64位的机器上)。很明显,上述代码会根据宿主机器的位数为intptr_t和uintptr_t适配相应的数据类型。
另外,如注释所言,定义这两个数据类型别名也是为了“void *”指针。
在C语言中,任何类型的指针都可以转换为void *类型,并且在将它转换回原来的类型时不会丢失信息。
七、muduo的缓冲区和定时器设计的简单介绍
注:这里只是一些简单总结,具体细节去看《Linux多线程服务端编程》第七章— 高效的多线程日志
7.1 muduo Buffer类的设计与使用
1、为什么非阻塞网络编程中应用层buffer是必须的
1、non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系统调用上,这样可以最大限度地复用 thread-of-control ,让一个线程能够服务于多个 socket 连接。这就是需要应用层 buffer 的原因。
2、TcpConnection必须要有output buffer:比如TCP发送了100kb的数据,但是咋write()调用,操作系统只接受了80kb,因为不想原地等待(非阻塞),所以要尽快交出控制权,返回事件循环中。
3、对于应用程序而言,它只管生成数据,它不应该关心到底数据是一次发送还是分成几次发送
2、TcpConnection为什么必须要有outputBuffer和inputBuffer?
这部分具体看书7.4.2
3、TCP粘包问题
网络库在处理“socket可读”事件的时候必须一次性把socket的数据一次性读完(从操作系统的buff搬运到应用层的buff上面),否则会反复触发POLLIN事件,造成busy-loop.这是因为采用的是LT模式
4、Buffer的设计
- 对外表现是一块连续的内存,以方便客户代码的编写
- 其size()可以自动增长,适应不同的消息
- 内部以vector来保存数据
5、Buffer的数据结构就是三个指针,一个数组,具体看书
6、Buffer其他设计方案
-
自己管理内存不用vector
-
zerocopy,注意不是严格意义上的0拷贝,数据从内核到用户空间有一次拷贝,如libevent2.0.x设计方案
内存不是连续的,是分快的。
7.2 Buffer所用到的读操作函数
readv writev
readv和writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读(scatter read)和聚集写(gather write).
#include <sys/uio.h>
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
两个函数的返回值:若成功则返回已读、写的字节数,若出错则返回-1
这两个函数的第二个参数是指向iovec结构数组的一个指针:
注:关于上面两个函数中第二个参数的说明,个人感觉是有问题的,const struct iovec (*iov)[],这样写的话iov才是一个指向iovec结构数据的指针。而上面函数中第二个参数明明是一个指向iovec结构的指针。
struct iovec {
void *iov_base; /* starting address of buffer 起始地址 */
size_t iov_len; /* size of buffer 要传输的字节数 */
};
writev以顺序iov[0],iov[1]至iov[iovcnt-1]从缓冲区中聚集输出数据。writev返回输出的字节总数,通常,它应等于所有缓冲区长度之和。
readv则将读入的数据按上述同样顺序散布到缓冲区中。readv总是先填满一个缓冲区,然后再填写下一个。readv返回读到的总字节数。如果遇到文件结尾,已无数据可读,则返回0。
7.3 muduo 定时器
1、与时间相关的常见任务
- 获取当前时间,计算时间间隔
- 失去转换与日期计算
- 定时操作
2、muduo使用的时间操作函数
- 计时函数的选择:只使用 gettimeofday(2) 获取当前时间
1.简介:
在C语言中可以使用函数gettimeofday()函数来得到时间。它的精度可以达到微妙
2.函数原型:
#include<sys/time.h>
int gettimeofday(struct timeval*tv,struct timezone *tz );
3.说明:
gettimeofday()会把目前的时间用tv 结构体返回,当地时区的信息则放到tz所指的结构中
4.结构体:
struct timeval{
long tv_sec; //秒
long tv_usec; //微妙
};
struct timeval{
__time_t tv_sec; // Seconds. tv_sec为Epoch到创建struct timeval时的秒数
__suseconds_t tv_usec; // Microseconds. tv_usec为微秒数,即秒后面的零头
};
struct timezone{
int tz_minuteswest; //和greenwich 时间差了多少分钟
int tz_dsttime; //type of DST correction
}
// test.c
#include <stdio.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <sys/time.h>
void Test()
{
int i;
struct timeval tv;
for(i = 0; i < 4; i++){
gettimeofday(&tv, NULL);
printf("%d\t%d\n", tv.tv_usec, tv.tv_sec);
sleep(1);
}
return 0;
}
int main()
{
Test();
return 0;
}
- muduo定时器函数的选择:只使用 timerfd_* 系列函数。
include <sys/timerfd.h>
<1>
函数原型:
int timerfd_create(int clockid, int flags);
函数作用:
创建一个定时器,返回一个文件描述符。
函数参数:
clockid:定时器类型
CLOCK_REALTIME:手动更改系统时间会触发定时器到时信号
CLOCK_MONOTONIC:手动更改系统时间不会触发定时器到时信号
CLOCK_REALTIME:是一个可设置的系统级时钟。CLOCK_MONOTONIC是一个不可设置的时钟
不受系统时钟不连续变化的影响(例如,手动更改系统时间)。当前的
可以使用clock_gettime(2)检索每个时钟的值。
flags:定时器选项
TFD_NONBLOCK:设置文件描述符为非阻塞的,若为未设置后期我们也可以使用fcntl()来进行设置
TFD_CLOEXEC:表示这个文件描述符在exec的情况下不会被继承下去。
/*在2.6.26及以上的Linux版本中,必须将flags指定为零。*/
返回值:返回一个文件描述符。
<2>
函数原型:
int timerfd_settime(int fd, int flags, const struct itimerspec *new_value,
struct itimerspec *old_value);
函数作用:
timerfd_settime()启动或关闭文件描述符fd引用的计时器。
函数参数:
fd:文件描述符
flags:
0:以启动一个相对计时器(new_value)。指定相对于的时间由clockid)
TFD_TIMER_ABSTIME:以启动一个绝对计时器(new_value。it_value为clockid指定的时钟指定一个绝 对时间;也就是说,计时器将过期当时钟的值达到new_value.it_value中指定的值 时)。
new_value:定时器现在的超时时间
old_value:定时器原来的超时时间
返回值:暂无。
struct timespec{
time_t tv_sec; //Seconds
long tv_nsec; //Nanseconds
};
struct itimerspec{
struct timespec it_interval; //表示定时器的间隔时间
struct timespec it_value; //表示第一次超时的时间
};
<3>
int timerfd_gettime(int fd, struct itimerspec *curr_value)
3、在非阻塞服务端编程中,绝对不能用 sleep() 或类似的办法(这是因为该函数的实现可能用到了SIGALM,与多线程水火不容) 来让程序原地等待,这会让主事件循环被挂起,程序失去响应。
7.4 muduo 定时器的介绍
muduo 定时器封装了 Timer.h 里面保存的是超时时间和回调函数, TimerQueue.h 使用set容器保存多个定时器, 然后在TimerQueue中使用timerfd_create创建一个timerfd句柄, 插入定时器A后先比较A的触发时间和TimerQueue的触发时间, 如果A的触发时间比其小就使用timerfd_settime重置TimerQueue的timerfd的触发时间, TimerQueue中的timerfd的触发时间永远与保存的定时器中触发时间最小的那个相同, 然后timerfd触发可读后, 遍历保存的多个定时器, 看看有没有同时到期的, 有执行回调函数。
TimerQueue的封装是为了让未到期的时间Timer有序的排列起来,这样,能够更具当前时间找到已经到期的Timer也能高效的添加和删除Timer。
所谓的到期与未到期,与当前在当前时间之前表示已经到期,之后则是未到期。为了方便计算,muduo重载了operator<主要是用来比较微秒大小。
到期的时间应该被清除去执行相应的回调,未到期的时间则应该有序的排列起来。
7.5 进程/线程间的等待通知(wait/notify)
介绍这部分主要是因为muduo的EventLoop中用于唤醒操作。
1、pipe 管道
2、socketpair
3、eventfd
eventfd 是一个比 pipe 更高效的线程间事件通知机制,一方面它比 pipe 少用一个 file descripor,节省了资源;另一方面,eventfd 的缓冲区管理也简单得多,全部“buffer” 只有定长8 bytes,不像 pipe 那样可能有不定长的真正 buffer。
#include <sys/eventfd.h>
int eventfd(unsigned int initval, int flags);
/*
eventfd—创建事件通知的文件描述符
函数介绍:eventfd()创建一个“eventfd对象”,它可以被用户空间应用程序用作事件等待/通知机制‐
和由内核通知用户空间应用程序的事件。
函数参数:
initval:对象包含无符号64位内核维护的整数(uint64_t)计数器。此计数器用指定的值初始化
在参数initval中。
flasg: EFD_CLOEXEC
在新的文件描述符上设置关闭-exec (FD_CLOEXEC)标志。
EFD_NONBLOCK
设置其为非阻塞标志,使用fcntl也可以改变。
EFD_SEMAPHORE
为从新的文件描述符读取提供类似信号的语义。
返回值:
正确返回一个文件描述符,错误返回-1。
*/
第二部分 muduo网络库介绍
在这些天看完muduo网络库之后,我大致对其做了一个分类,重点将其分为几个版块,接下来的介绍大致围绕这几个版块开始
- 事件循环版块:EventLoop、EventLoopThread、EventLoopThreadPool、Poller(Pollpoller、Epollpoller)
- 计时与定时器版块:TimeStamp、Timer、TimerId、TimerQueue
- 网络版块
- 网络库通信函数的封装版块:Endian.h、Socket、SocketsOps、InetAddress、Callbacks.h
- 服务端监听版块:Acceptor、TcpServer
- 网络库链接版块:Cnonector、TcpClient
- 网络库连接对象版块:TcpConnection
- 日志版块:包括 Logging、LogStream、LogFile、Processinfo、FileUtil、AsyncLogging
- 缓冲区版块:buffer.h buffer.cpp
- muduo基础库的一部分源代码文件:ThreadPool、Singleton。
一、muduo架构解析
注:本篇转载CSDN两篇博客,本人感觉写的不错,作为日后再学习之用。
第一篇:链接地址:https://blog.csdn.net/Swartz2015/article/details/56675082
第二篇:链接地址:https://blog.csdn.net/z591826160/article/details/99737556?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-1.channel_param&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-1.channel_param
1.1 第一篇:
muduo是一个基于Reactor模式的C++网络库。它采用非阻塞I/O模型,基于事件驱动和回调。我们不仅可以通过muduo来学习linux服务端多线程编程,还可以通过它来学习C++11。
Reactor是网络编程的一般范式。我们这里从reactor模式为出发点,根据Reactor模式的特点剖析muduo的架构设计。根据wiki的定义:
The reactor design pattern is an event handling pattern for handling
service requests delivered concurrently to a service handler by one or
more inputs. The service handler then demultiplexes the incoming
requests and dispatches them synchronously to the associated request
handlers.
我们可以知道,Reactor模式的基础是事件驱动,事件源可以有多个,并且会并发地产生事件。Reactor模式的核心是一个事件分发器和多个事件处理器,多个事件源向事件分发器发送事件,并要求事件分发器响应,reactor模式的设计难点也是在事件分发器,它必须能够有条不紊地把响应时间分派到合适的事件处理器中,保证事件处理的最小延迟。事件处理器主要是负责处理事件的业务逻辑,这是关系到具体事件的核心,因此和事件分发器不一样,它并不太具有一般性。
Reactor模式的特点可以很自然地应用到C/S架构中。在C/S架构的应用程序中,多个客户端会同时向服务端发送request请求。服务端接收请求,并根据请求内容处理请求,最后向客户端发送请求结果。这里,客户端就相当于事件源,服务端由事件分发器和事件处理器组成。分发器的任务主要是解析请求和将解析后的请求发送到具体的事件处理器中。
从技术的层面来说,怎么把“事件”这个概念放到“请求”上,也就是怎么样使得请求到来可以触发事件,是一个难点。从设计的层面上来说,怎么样分发事件使得响应延迟最小,并保持高可扩展性是难点(架构能够较好地适应各种事件的处理和事件数量的变化)。对于技术层面,linux上的解决方案是:epoll,select等。而设计层面,muduo提供了较好的解决方案。
Muduo的基础设施是epoll,并在此基础上实现了one-thread-one-loop和thread-pool设计方案。也就是将事件处理器设置成线程池,每个线程对应一个事件处理器;因为事件处理器主要处理的是I/O事件,而且每个事件处理器可能会处理一个连接上的多个I/O事件,而不是处理完一个事件后直接断开,因此muduo选择每个事件处理器一个event-loop。这样,连接建立后,对于这条连接上的所有事件全权由它的事件处理器在event-loop中处理。
我们可以根据上面的reactor架构图,简单地绘制出muduo的架构图:
如图所示,客户端首先和服务端建立连接,如图橙色线所示。建立连接之后将这个连接分发到具体的Eventloopthread中(所有的eventloopthread由server中的一个eventloopthreadpool线程池管理),这部分主要由server中的Acceptor完成。后续client就不再和Acceptor发生关系了。因此可以看出,建立连接之后,client直接和Eventloopthread关联,不再经过Acceptor。
由于连接本身也是一个事件,因此Acceptor的工作是等待事件和分发事件,因此它也是在一个eventloop中。
下面我们看一下Tcpserver类成员,其实类中的*loop_指向的其实就是Acceptor所在的eventloop。因此Acceptor的eventloop并不是存在于eventloopthreadpool中的。不过后面我们会看到,这个eventloop结构也是会传入到eventloopthreadpool结构中,由eventloopthreadpool的baseloop标识,这主要是为了管理server中的所有eventloop方便。
至此我们便介绍完了muduo中的Tcpserver架构。后面我们将从此展开,深入到具体细节中。比如怎么管理eventloopthread,建立好的连接怎么放入到eventloopthread中,以及eventloopthread怎么管理和客户端直接的I/O连接,eventloop怎么管理各个事件的处理逻辑等等。最后我们还将介绍muduo实现的一些架构之外的技术细节,比如缓冲区管理,日志系统,定时器管理等等。
————————————————
版权声明:本文为CSDN博主「gswen」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/Swartz2015/article/details/56675082
1.2 第二篇:
本文以了解总体的架构后,从每个类的责任以及功能入手,深入各个类的依赖关系,最终按照运行流程梳理,了解框架的运行机理.
目前的目标:
了解框架结构,各个类的职责,各个类的依赖关系,最终能够解释客户端一个连接到达后的运行流程
如何管理文件描述符
如何派发连接进行处理
如何设计不同的事件响应函数
poll/epoll如何介入到框架中
后续目标:
多线程的并发同步
定时任务如何管理
性能效果测试
设计模式总结分析
架构分析
常见服务器模型
模型 | 阻塞I/O | 多核 | 开销 | 工作模式 | 备注 |
---|---|---|---|---|---|
accept+read/write | √ | × | 低 | 连接按顺序处理 | 无并发性,吞吐量很低 |
accept+fork | √ | √ | 高 | 一个连接一个进程 | 适合并发连接数不多的长连接,进程创建,切换,销毁时消耗的系统资源过多 |
prefork | √ | √ | 高 | 进程复用,一个连接一个进程 | 减少进程创建以及销毁的开销 |
accept+thread | √ | √ | 中 | 一个连接一个线程 | 对accept+fork版在创建,切换,销毁开销方面的优化 |
prethread | √ | √ | 中 | 线程复用,一个连接一个线程 | 减少线程创建以及销毁的开销 |
poll(单线程reactor) | × | × | 低 | I/O复用 | 单线程对多个socket下的事件进行管理,但同一时刻任只能处理一个事件 |
reactor+thread(request) | × | √ | 中 | I/O复用,一个请求一个线程 | I/O由reactor线程负责,请求交由线程负责,一个请求一个线程,计算过程无序 |
reactor+thread(connection) | × | √ | 中 | I/O复用,一个连接一个线程 | 连接建立由reactor负责,连接的I/O由线程负责,一个连接一个线程,保证计算过程有序 |
reactor+thread pool | × | √ | 中 | I/O复用,线程处理计算 | reactor做I/O,线程做计算.适合I/O压力小且计算任务独立 |
reactors in process[nginx] | × | √ | 高 | I/O复用,一个进程多个连接 | 每个进程搭载一个reactor,main reactor处理连接请求并交付给sub reactor,连接在多个sub reactor轮询的处理 |
reactors in thread[muduo] | × | √ | 中 | I/O复用,一个线程多个连接 | reactors in process的线程版,每个线程搭载一个reactor |
reactors + thread pool | × | √ | 中 | I/O复用,一个线程多个连接,线程处理计算任务 | 既有多个reactor分发连接,又有线程池处理计算任务,最灵活的配置 |
总览
总体架构
由常见服务器模型中可以知道muduo采用一个线程包含一个reactor,一个reactor内管理多个连接的模型.主线程即main reactor负责监听socket,并将accept的连接轮询的交付给其他线程的sub reactor处理,各个sub reactor负责与远端通信.
muduo中暴露接口的方式采用的是注册回调函数的形式.并且采用非阻塞的套接字.
类的职能
-
Socket
套接字的管理工具类,内部维护一个文件描述符.对socket的创建,bind,listen,accept进行简单封装,含有诸如设置SO_KEEPALIVE, SO_REUSEPORT等套接字选项的接口.
-
Channel
对套接字的一层封装,内部维护一个套接字的文件描述符,套接字对应的读,写,关闭,错误事件的回调函数,套接字正在处理事件的标记.套接字对应的回调函数注册在这里,并等待被调用.
-
Acceptor
服务器端监听套接字(listen_fd)的封装,内部维护一个监听套接字的Channel以及Socket,以及新连接创建成功的回调函数Acceptor负责监听listen_fd,其Channel管理的套接字可读,就意味着有新的连接完成了三次握手到达.因此读事件回调函数注册为accept的一个封装,负责接受新的连接并建立连接套接字(connect_fd).并将connect_fd作为参数调用注册的新连接回调函数,供上层使用处理.
-
Connector
客户端套接字的封装,内部维护一个Channel,以及新连接创建成功的回调函数Connector负责根据给定的服务器端口以及地址创建套接字,并与远端建立连接(connect).其Channel管理的套接字可写,就意味着连接可能已经完成建立.因此写事件回调函数注册为对连接状态的检查,如果出现错误则重试,否则此时连接已经成功创建,将套接字作为参数调用注册的新连接回调函数,供上层使用.
补充一下,这里为什么说连接可能已经完成建立? (详细可见TCP-socket异常情况) 因为在非阻塞套接字下,connect返回时不一定代表着连接已经建立完成,还可能因为对端繁忙连接超时,或者connect的过程中被信号中断提前返回. 对于其他函数,重新调用函数即可应对,而connect则不行.因为其他函数的状态是单一的,包括read,write,accept都是有操作成功和失败的两个结果,失败后不会对原结构造成影响.(accept只是尝试从就绪队列中取一个已完成三次握手的连接),而connect在失败时,可能已经向对方发送了握手连接并到达,如果再次调用可能造成错误. 此时则需要使用getsockopt判断是否成功连接,失败则再重试.
-
TCPConnection
对一个完成连接的双方的套接字的封装,内部维护一个Channel,输入输出缓存,消息接收以及发送的回调函数.TCPConnection负责整个底层消息的发送接收,以及暴露给上层数据收发的处理接口,其Channel管理的套接字的读写事件就分别的对应着消息的收发事件,消息接收成功时以接收到的内容和时间为参数调用注册的回调函数供上层使用.
-
Poller
包含Poll以及Epoll的两种实现,内部维护一个注册事件列表EventList.Poller是Poll以及Epoll在事件阻塞I/O复用的一种抽象,Poller的主要职责是阻塞一段时间,检测注册给Poller的事件是否被触发,并将触发事件的Channel列表返回给上层
-
EventLoop
负责处理整体的循环逻辑,内部维护一个Poller,唤醒Channel,触发事件的Channel列表
EventLoop即对应reactor的职责,在每个循环内,调用Poller获取触发了事件的Channel,并调用Channel对应时间的回调函数.
另外在muduo的实现中,存在一个PendingFunctors的过程.它是在每个EventLoop主循环结束后允许附带处理的计算过程或处理过程.这是为了在处理事件触发的过程中,想要调整Channel的读写状态提供的一个入口.
到此为止,整个服务器/客户端所述的各种步骤已经完成了.剩下的就是进行再一层的封装,提供暴露给用户使用的接口.这便是TCPClient以及TCPServer所做的.
除此之外,TCPServer还对线程池进行了管理,所以这里对TCPServer再介绍一下,TCPClient便不再赘述
-
TCPServer
负责向用户暴露消息通信的接口并向线程池内的EventLoop(Sub Reactor)派发任务,内部维护主EventLoop(Main Reactor), Acceptor, 包含EventLoop的线程池.TCPServer在启动时,进行线程池初始化,注册Acceptor的新连接回调函数并调用Acceptor创建套接字进行监听.
在新连接回调函数中,建立TCPConnection对象,注册用户定义的消息通信接口至TCPConnection,并从线程池内轮询的获取一个线程,将TCPConnection挂载在该线程的EventLoop内,完成任务的派发
工作流程
其余事项
关于线程池线程数量的修改实现的思考
在TCPServer中,暴露了设置线程池大小的接口.但在整个过程中,线程真正数量的改变只在TCPServer调用start时,间接调用EventLoopThreadPool的start.在其中做了线程的创建以及启动EventLoop.之后便是等待TCPServer轮询到该线程挂载任务后处理了.
因此这里出现一个问题,在TCPServer调用start后,线程的真正数量不会再改变,哪怕重新调用了调整线程池大小的接口.但是看到轮询的具体实现如下
EventLoop* EventLoopThreadPool::getNextLoop()
{
baseLoop_->assertInLoopThread();
assert(started_);
EventLoop* loop = baseLoop_;
if (!loops_.empty())
{
// round-robin
loop = loops_[next_];
++next_;
if (implicit_cast<size_t>(next_) >= loops_.size())
{
next_ = 0;
}
}
return loop;
}
轮询的实现类似于(next++)%loops_.size()的方式,因此线程池的大小不再受线程池内部的numThreads_ 控制,因此线程真正的数量大小只能通过操作loops_ 来控制,但是TCPServer中对EventLoopThreadPool的访问权限是private的.
所以系统启动后,线程池内的线程多少是无法动态改变的,只有等到服务器终止后回收线程.
不知道为何没有设计成TCPServer接受一个EventLoopThreadPool对象,默认为系统实现.通过继承重载实现自己的可回收空闲线程或者扩大线程池.
————————————————
版权声明:本文为CSDN博主「DDullahan」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/z591826160/article/details/99737556
二、面向对象与基于对象的介绍和设计
2.1 面向对象的风格
首先我们先设计一个线程类,封装了C语言的线程接口。
/*Thread_OO.h*/
#ifndef THREAD_OO_H
#define THREAD_OO_H
#include <thread>
#include <pthread.h>
#include <functional>
class Thread
{
public:
Thread();
virtual ~Thread();
void start();
void join();
virtual void run() = 0;
static void* runInThread(void *arg);
public:
pthread_t m_pid;
bool m_isJoin;
bool m_isStart;
};
#endif // THREAD_OO_H
#endif // THREAD_OO_H
/*Thread_OO.cpp*/
#include "thread.h"
#include <iostream>
#include <unistd.h>
Thread::Thread()
{
m_pid = 0;
m_isJoin = false;
m_isStart = false;
}
Thread::~Thread()
{
if(m_isStart && !m_isJoin)
{
pthread_detach(m_pid);
}
}
void Thread::start()
{
if(!m_isStart)
{
m_isStart = true;
pthread_create(&m_pid, NULL, runInThread, this);
}
}
void Thread::join()
{
if(m_isStart)
{
m_isJoin = true;
pthread_join(m_pid,NULL);
}
}
void* Thread::runInThread(void* arg)
{
Thread* p = static_cast<Thread*>(arg);
if(NULL != p)
{
p->run();
}
return nullptr;
}
/*main.cpp*/
#include <iostream>
#include <unistd.h>
#include "thread.h"
class WorkThread: public Thread
{
public:
void run();
};
void WorkThread::run()
{
for(int i = 0; i < 5; i++)
{
std::cout << "WorkThread::run" << std::endl;
sleep(1);
}
}
int main()
{
WorkThread work;
work.start();
work.join();
return 0;
}
我们线程类中要使用pthread_create(),想要用run()函数作为线程运行函数。run()是普通的成员函数,隐含的第一个参数是this指针,调用的时候遵循thiscall约定,某一个寄存器会保存this指针,而prhtread_create()的线程运行函数需要调用普通入口函数。
这个问题有一下几种解决方案:
1.使用一个全局函数,但是这会向外界暴露这个函数。
2.类内部使用static修饰线程运行函数,将this指针作为pthread_create()第四个参数传递。
派生类对象调用t.start(),t调用成员函数,隐含了了一个t的this指针,调用的start()其实调用的是基类的start(),start()中pthread_create()又把this指针传给线程运行函数,所以是把派生类对象又传到了runInThread(),然后在该函数中使用基类指针指向派生类对象,基类指针调用派生类实现的虚函数,这就是虚函数的多态,同时也是虚函数回调应用函数。
上面是一个派生类调用基类start()方法,基类中又回调了派生类的run()方法。静态成员函数不能调用非静态成员,不过有时我们为了实现可以传递this指针,通过this指针调用即可。
线程对象的生命周期 与 线程的生命周期是不一样的。
在栈上分配的对象生命周期是有局部作用域决定的,动态创建的对象可以使用delete销毁。
2.2 基于对象风格
首先我们先设计一个线程类,封装了C语言的线程接口。
/*Thread_BO.h*/
#ifndef THREAD_OO_H
#define THREAD_OO_H
#include <pthread.h>
#include <unistd.h>
#include <functional>
typedef std::function<void(void)> RunCallBack;
class Thread
{
public:
Thread(RunCallBack runFunc);
~Thread();
void start();
void join();
static void* runInThread(void *arg);
public:
bool m_isJoin;
bool m_isStart;
pthread_t m_pid;
RunCallBack m_runFunc;
};
#endif // THREAD_OO_H
/*Thread_BO.cpp*/
#include "thread.h"
#include <iostream>
Thread::Thread(RunCallBack runFunc)
{
m_pid = 0;
m_isJoin = false;
m_isStart = false;
m_runFunc = std::move(runFunc);
}
Thread::~Thread()
{
if(m_isStart && !m_isJoin)
{
pthread_detach(m_pid);
}
}
void Thread::start()
{
if(!m_isStart)
{
m_isStart = true;
pthread_create(&m_pid, NULL, runInThread, this);
}
}
void Thread::join()
{
if(m_isStart)
{
m_isJoin = true;
pthread_join(m_pid,NULL);
}
}
void* Thread::runInThread(void* arg)
{
Thread* p = static_cast<Thread*>(arg);
if(NULL != p)
{
p->m_runFunc();
}
return nullptr;
}
/*main.cpp*/
#include <iostream>
#include "thread.h"
void workFunction(void)
{
for(int i = 0; i < 5; i++)
{
std::cout << "workFunction::run" << std::endl;
sleep(1);
}
}
class WorkClass
{
public:
void work(void);
};
void WorkClass::work(void)
{
for(int i = 0; i < 5; i++)
{
std::cout << "WorkClass::work" << std::endl;
sleep(2);
}
}
int main()
{
Thread t1(std::bind(workFunction));
WorkClass work;
Thread t2(std::bind(&WorkClass::work, &work));
t1.start();
t2.start();
t1.join();
t2.join();
return 0;
}
2.3 两者的区别
简单理解它们的区别就是,面向对象的编程思想是不停地继承,重写,然后注册基类的回调,关联性很强。
而基于对象的编程风格就不需要继承了,我们需要在应用类中包含其他的对象。同样会注册回调。
举例注册事件到网络库:
面向对象风格,用一个EchoServer继承TcpServer(抽象类),实现相关的接口。
基于对象的风格,用一个EchoServer包含一个TcpServer(具体类)对象,可以在构造函数中使用boost::bind绑定回调函数。
三、关于muduo的源码解析
1、对于muduo的源码我基本已经看完,并手写了一遍,在关键的地方做出了注释,源代码见外部文件夹LMuduoNet。
2、muduo源码剖析我这里推荐几个优秀的博客:
muduo源码剖析28篇:
https://blog.csdn.net/freeelinux/category_9267542.html
muduo日志系统的学习:
https://blog.csdn.net/luotuo44/article/details/19252535
https://blog.csdn.net/luotuo44/article/details/19254157
四、附上几张特别重要的网络库分析图
4.1 muduo的类图
4.2 当有事件发生时的时序图
4.3 有连接到来的时序图
4.4 定时器触发的时序图
4.5 EventLoop的计算任务执行的顺序图
4.6 用户增加定时器程序内部的调用顺序图
4.7 客户端建立连接服务端的时序图
当连接到来时,创建一个TcConnection对象,立刻用shared_ptr来管理,引用计数为1,
在Channel中维护一个weak_ptr(tie_),将shared_ptr对象来赋值给tie_,引用计数仍为1。
4.8 客户端断开连接服务端的时序图
4.9 EventLoopThreadPool (也可以说是主从reactor)
4.10 服务端建立应用缓冲区的使用过程
4.11 Log日志写的过程
4.12 服务端主动关闭连接
陈硕答复如下:
Muduo TcpConnection 没有提供 close,而只提供 shutdown ,这么做是为了收发数据的完整性。
TCP 是一个全双工协议,同一个文件描述符既可读又可写, shutdownWrite() 关闭了“写”方向的连接,保留了“读”方向,这称为 TCP half-close。如果直接 close(socket_fd),那么 socket_fd 就不能读或写了。
用 shutdown 而不用 close 的效果是,如果对方已经发送了数据,这些数据还“在路上”,那么 muduo 不会漏收这些数据。换句话说,muduo 在 TCP 这一层面解决了“当你打算关闭网络连接的时候,如何得知对方有没有发了一些数据而你还没有收到?”这一问题。当然,这个问题也可以在上面的协议层解决,双方商量好不再互发数据,就可以直接断开连接。
等于说 muduo 把“主动关闭连接”这件事情分成两步来做,如果要主动关闭连接,它会先关本地“写”端,等对方关闭之后,再关本地“读”端。练习:阅读代码,回答“如果被动关闭连接,muduo 的行为如何?” 提示:muduo 在 read() 返回 0 的时候会回调 connection callback,这样客户代码就知道对方断开连接了。
Muduo 这种关闭连接的方式对对方也有要求,那就是对方 read() 到 0 字节之后会主动关闭连接(无论 shutdownWrite() 还是 close()),一般的网络程序都会这样,不是什么问题。当然,这么做有一个潜在的安全漏洞,万一对方故意不不关,那么 muduo 的连接就一直半开着,消耗系统资源。
完整的流程是:我们发完了数据,于是 shutdownWrite,发送 TCP FIN 分节,对方会读到 0 字节,然后对方通常会关闭连接,这样 muduo 会读到 0 字节,然后 muduo 关闭连接。(思考题,在 shutdown() 之后,muduo 回调 connection callback 的时间间隔大约是一个 round-trip time,为什么?)
另外,如果有必要,对方可以在 read() 返回 0 之后继续发送数据,这是直接利用了 half-close TCP 连接。muduo 会收到这些数据,通过 message callback 通知客户代码。
那么 muduo 什么时候真正 close socket 呢?在 TcpConnection 对象析构的时候。TcpConnection 持有一个 Socket 对象,Socket 是一个 RAII handler,它的析构函数会 close(sockfd_)。这样,如果发生 TcpConnection 对象泄漏,那么我们从 /proc/pid/fd/ 就能找到没有关闭的文件描述符,便于查错。
muduo 在 read() 返回 0 的时候会回调 connection callback,然后把 TcpConnection 的引用计数减一,如果 TcpConnection 的引用计数降到零,它就会析构了。