muduo C++网络库——阅读笔记
前言
==本书重点:==多线程网络服务器的一种IO模型,即one loop per thread。
掌握两种基本的同步原语就可以满足各种多线程同步的功能需求,还能写出更易用的同步设施。掌握一种进程间通信方式和一种多线程网络编程模型就足以应对日常开发任务,编写运行于公司内网环境的分布式服务系统。(本书不涉及分布式存储系统,也不涉及 UDP )
注意几个中文C++术语:
- 对象实体:instance
- 函数重载决议:resolution
- 模板具现化:instantiation
- 覆写虚函数:override
- 提领指针:dereference
容量单位 kB、MB、GB表示的字节数分别为103、106、109
特别强调准确数值时,分别用:KiB、MiB、GiB表示210、220、230字节
muduo 是一个基于非阻塞IO和事件驱动的现代C++网络库,原生支持 one loop per thread 这种IO模型。
第一部分“ C++ 多线程系统编程”:
- 考察多线程下的对象生命期管理
- 线程同步方法
- 多线程与C++的结合
- 高效的多线程日志
第二部分“ muduo 网络库”:
- 介绍使用线程的非阻塞网络库编写网络应用程序的方法
- muduo的设计与实现
第三部分“工程实践经验谈”:
- 介绍分布式系统的工程化开发方法
- C++在工程实践中的功能特性取舍
第四部分“附录”:
- 分享网络编程
- C++语言的学习经验
第1章 线程安全的对象生命期管理
当析构函数遇到多线程,读者应具有C++多线程编程经验,熟悉互斥器(mutex)、竞态条件(race condition)等概念,了解智能指针,知道Observer设计模式
当一个对象能被多个线程同时看到时,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件(race condition):
- 在即将析构一个对象时,从何而知此刻是否有别的线程正在执行该对象的成员函数?
- 如何保证在执行成员函数期间,对象不会在另一个线程被析构?
- 在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?
本文试图以shared_ptr
一劳永逸地解决这些问题。
1、MutexLock 与 MutexLockGuard
- MutexLock封装临界区(critical section),这是一个简单地资源类,用RAII手法封装互斥器地创建与销毁。在Linux下是pthread_mutex_t。
- MutexLockGuard封装临界区地进入和退出,即加锁和解锁。MutexLockGuard一般是个栈上对象,它地作用域刚好等于临界区域
这两个class都不允许拷贝构造和赋值。
1.1 线程安全的Counter示例
只需用同步原语保护其内部状态。
//A thread_safe counter
class Counter:boost::noncopyable{
public:
//默认情况下:拷贝构造函数和赋值构造函数应该被私有化
Counter():value_(0){}
int64_t value() const;
int64_t getAndIncrease();
private:
int64_t value_;
mutable MutexLock mutex_;
};
int64_t Counter::value() const{
MutexLockGuard lock(mutex_);//lock的析构会晚于返回对象的构造
return value_; //因此有效地保护了这个共享数据。
}
int64_t Counter::getAndIncrease(){
MutexLockGuard lock(mutex_);
int64_t ret = value++;
return ret;
}
- 每个Counter对象有自己的mutex_,因此不同对象之间不构成锁争用。
思考:如果mutex_是static,是否影响正确性和/或性能?
如果将 mutex_
声明为 static
,会对正确性和性能产生一些影响。
1、影响正确性:
- 当
mutex_
是static
时,所有Counter
对象实例将共享同一个互斥锁。这意味着多个线程在访问不同的Counter
对象时会互斥地竞争同一个锁。这可能导致死锁或竞争条件的发生。 - 如果多个线程同时调用
value()
或getAndIncrease()
方法,由于它们共享同一个互斥锁,可能会导致线程之间的争用和阻塞,从而影响程序的正确性。
2、影响性能:
- 将
mutex_
声明为static
后,多个线程在访问Counter
对象时会共享同一个互斥锁。这意味着只有一个线程能够同时执行value()
或getAndIncrease()
方法,其他线程必须等待锁的释放。这会降低并发性能,增加线程之间的竞争和等待时间。 - 在高并发的情况下,共享同一个静态互斥锁可能成为瓶颈,限制了程序的吞吐量和性能。
1.2 对象的创建不要泄露this指针
对象构造要做到线程安全:即
- 不要在构造函数中注册任何回调;
- 也不要在构造函数中把this传给跨线程的对象;
- 即便在构造函数的最后一行也不行。
//不要这么做
class Foo:public Observer{
public:
Foo(Observable* s){
s->register_(this);//错误:非线程安全
}
virtual void update();
}
//对象构造的正确方法:
class Foo:public Observer{
public:
Foo();
virtual void update();
//另外定义一个函数,在构造之后执行回调函数的注册工作
void observe(Observable* s){
s->register_(this);
}
};
Foo* pFoo = new Foo;
Observable* s = getSubject();
pFoo->observe(s);//二段式构造,或者直接写s->register_(pFoo);
1.3 销毁注意事项
- 在单线程中的对象析构:注意避免悬空指针和野指针;
- 在多线程中,存在太多的竞态条件,对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行(关键是不要同时读写共享状态),即每个成员函数的临界区不重叠。
- 隐含条件:成员函数用来保护临界区的互斥器本身必须是有效的。但是析构函数破坏了这一假设,它会把mutex成员变量销毁掉。
悬空指针:指向已经销毁的对象或已经回收的地址
野指针:未经初始化的指针
1.4 mutex不是办法
mutex只能保证函数一个接一个地执行,如下:它试图用互斥锁来保护析构函数:
Foo:~Foo(){
MutexLockGuard lock(mutex_);
//free internal state(1)
}
//-----------------------------------------------------------------//
void Foo:update(){
MutexLockGuard lock(mutex_);//(2)
//make use of internal state
}
此时,有A、B两个线程都能看到Foo对象x,线程A即将销毁x,而线程B真准备调用x->update()。
extern Foo* x;//visible by all threads
//threadA
delete x;
x = NULL;//helpess
//threadB
if(x){
x->update();
}
尽管线程A在销毁对象之后把指针置为了NULL,尽管线程B在调用x地成员函数之前检查了指针x的值,但还是无法避免一种race condition:
- 线程A执行到了析构函数的(1)处,已经持有了互斥锁,即将继续往下执行。
- 线程B通过了if(x)检测,阻塞在(2)处。
但是,析构函数会把mutex_销毁,那么(2)处有可能永远阻塞下去,有可能进入“临界区”,然后core dump,或者发生其他更糟糕的情况。
这个例子至少说明delete对象之后把指针置为NULL根本没用。
1.5 作为数据成员的mutex不能保护析构
作为数据成员的MutexLock只能用于同步本class的其他数据成员的读和写,它不能保护安全地析构。因为MutexLock成员地生命期最多与对象一样长,而析构动作可说是发生在对象身故之后(或身亡之前)。
如果要同时读写一个class的两个对象,有潜在的死锁可能。
void swap(Counter& a, Counter& b){
MutexLockGuard aLock(a.mutex_);
MutexLockGuard bLock(b.mutex_);
int64_t value = a.value_;
a.value_ = b.value_;
b.value_ = value;
}
//如果线程A执行swap(a,b);而同时线程B执行swap(b,a);就有可能死锁。operator=()也是类似的道理。
Counter& Counter::operator=(const Counter& rhs){
if(this == &rhs)
return *this;
MutexLockGuard myLock(mutex_);
MutexLockGuard itsLock(rhs.mutex_);
value_ = rhs.value_;//改成value_ = rhs.value()会死锁
return *this;
}
- 一个函数如果要锁住相同类型的多个对象,为了保证始终按相同的顺序加锁,我们可以比较mutex对象的地址,始终先加锁地址较小的mutex。
1.6 线程安全的Observer有多难
对象的三种关系:composition、aggregation、association
- composition(组合/复合):对象x的生命期由其唯一的拥有者owner控制,owner析构的时候会把x也析构掉。
- association(关联/联系):一种很宽泛的关系,它表示一个对象a用到了另一个对象b,调用了后者的成员函数。即(a持有b的指针(或引用),但是b的生命期不由a单独控制。)
- aggregation(聚合):从形式上看与association相同,除了a和b有逻辑上的整体与部分关系。
简单的解决办法是:只创建不销毁
程序使用一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货,就重复利用现有的对象,否则就新建一个。对象用完了,不是直接释放掉,而是放回池子里。这个办法有其缺点,但至少能避免访问失效对象的情况发生。
这种办法的缺点:
-
对象池的线程安全,如何安全地、完整地把对象放回池子里,防止出现“部分放回”地竞态?(线程A认为对象x已经放回了,线程B认为对象x还活着。)
1、使用原子操作:在将对象放回对象池的过程中,可以使用原子操作来确保操作的原子性。例如:使用原子的计数器或原子的引用类型变量来记录对象池中的对象数量,再放回对象时进行递增操作。
2、使用同步机制:使用线程安全的同步机制来保护对象池的操作。可以使用互斥锁(Mutex)或读写锁(ReadWriteLock)来控制对对象池的访问,确保只有一个线程能够修改对象池的状态。
3、使用条件变量:可以结合条件变量来实现线程间的通信和同步。当对象池已满时,新的线程可以等待条件变量的信号,直到有其他线程将对象池中取出,释放出空位,在放入新的对象。
4、使用线程安全的数据结构:可以使用线程安全的队列(如Concurrent Linked Queue)或列表(CopyOnWriteArrayList)来作为对象池的容器,这样就可以避免竞态条件的发生。
5、对象的状态的重置:在将对象放回对象池之前,需要确保对象的状态被完整地重置,以免影响其他线程对对象地使用。例如:将对象地属性重置为初始值,清除对象地临时状态。
-
全局共享数据引发地lock contention(锁竞争),这个集中化地对象会不会把多线程并发地操作串行化?
当多个线程需要同时对共享数据进行修改时,它们会竞争获取同一个锁,如果一个线程获取锁,其他线程就需要等待,这样就会导致并发操作变为串行化操作。
为了解决锁竞争问题,可以考虑一些策略。例如:使用更细粒度地锁,将对象池划分为多个子池,每个子池使用独立地锁进行管理,以减少锁竞争的范围。另外,可以使用无锁数据结构或者无锁算法,避免使用锁来实现对象池的管理,从而提高程序的并发性能。
-
如果共享对象地类型不止一种,那么是重复实现对象池还是使用类模板?
使用类模板
类模板可以将共享对象地类型作为模板参数,在类模板中定义相应地数据结构和操作方法。通过实例化模板,可以得到具体类型地对象池。例如:可以定义一个类模板ObjectPool,其中T是对象地类型;然后通过实例化该模板,可以得到不同类型对象的对象池,如ObjectPool、ObjectPool等。
-
会不会造成内存泄露与分片?因为对象池占用地内存只增不减,而且多个对象池不能共享内存(想想为何)。
1、内存泄漏:对象池在使用过程中,对象被去除并使用,但未被正确放回池中。如果发生这种情况,对象将无法被垃圾回收,从而导致内存泄漏。为避免内存泄漏,使用对象池时应确保在不再需要对象时将其正确放回池中。
2、分片问题:分片问题是指对象池分配的内存不可合并,从而导致内存碎片化。当对象从对象池中分配出去后,如果不再需要并且无法归还到对象池中,这块内存就会变为无法利用的碎片。随着时间的推移,这些碎片会逐渐增多,导致内存的浪费。
3、不能共享内存:因为对象池通常是基于特定的对象类型来管理对象的,每个对象池都会维护一块属于自己的内存空间用于存储对象。每个对象池都有自己的分配、回收、管理机制,这是为了确保对象的可用性和线程安全性。如果多个对象池共享同一块内存空间,会导致对象的分配和回收混乱,可能会出现造成对象被错误地使用或释放,导致程序错误。如果需要在多个对象池之间共享数据,可以通过其他地方式,如:消息传递、共享引用。
1.7 智能指针的由来
空悬指针
有两个指针p1和p2,指向堆上的同一个对象Object,p1和p2位于不同的线程中。假设线程A通过p1指针将对象销毁了(尽管把p1置为了NULL),那p2就成了空悬指针。
一个“解决办法”
一个解决空悬指针的办法是,引入一层间接性,让p1和p2所指的对象永久有效。比如图1-2中的proxy对象,这个对象,持有一个指向Object的指针。(从C语言的角度,p1和p2都是二级指针)
当销毁Object之后,proxy对象继续存在,其值变为0.而p2也没有变成空悬指针,它可以通过查看proxy的内容来判断Object是否还活着。
但也有一种可能,比如p2看第一眼的时候proxy不是零,正准备去调用Object的成员函数,期间对象已经被p1给销毁了。
问题在于,何时释放proxy指针呢?
一个更好的“解决办法”
为了安全地释放proxy,我们可以引入引用计数,再把p1和p2都从指针变成对象sp1和sp2。proxy现在有两个成员,指针和计数器。
1、一开始,有两个引用,计数值为2
2、sp1析构了,引用计数地值减为1
3、sp2也析构了,引用计数降为0,可以安全地销毁proxy和Object了
一个万能地解决方案
引入另外一层间接性,用对象来管理共享资源(如果把Object看作资源地话,亦即handle/body管用技法)。即智能指针。
引用计数是自动化资源管理地常用手法,当引用计数降为0时,对象(资源)即被销毁。
weak_ptr也是一个引用技术型智能指针,但是它不增加对象的引用次数,即弱引用。
1.8 系统地避免各种指针错误
C++里可能出现地内存问题大致有这么几个方面:
- 缓冲区溢出(buffer overrun)
- 空悬指针/野指针
- 重复释放(double delete)
- 内存泄漏(memory leak)
- 不配对地new[]/delete
- 内存碎片(memory fragmentation)
使用智能指针可以很轻易地解决前面5个问题,解决第6个问题需要别的思路:
- 缓冲区溢出:用std::vector/std::string或自己编写Buffer class来管理缓冲区,自动记住用缓冲区地长度,并通过成员函数而不是裸指针来修改缓冲区。
- 空悬指针/野指针:用shared_ptr/weak_ptr,这正是本章地主题。
- 重复释放:用scoped_ptr,只在对象析构地时候释放一次。
- 内存泄漏:用scoped_ptr,对象析构地时候自动释放内存。
- 不配对地new[]/delete:把new[]统统替换为std::vector/scoped_array。
在这几种错误里边,内存泄漏相对危害性较小,因为它只是借了东西不归还,程序功能在一段时间内还算正常。其他如缓冲区溢出或重复释放等致命错误可能会造成安全性方面地严重后果。
1.9 应用到Observer上
解决Observer模式地竞态条件:让Observable保存weak_ptr
class Observable {//not 100% thread safe!
public:
void register_(weak_ptr<OObserver> x);//参数类型可用const weak_ptr<Observer>&
//void unregister(weak_ptr<Observer> x);//不需要它
void notifyObserver();
private:
mutable MutexLock mutex_;
std::vector<weak_ptr<Observer>> observers_;
typedef std::vector<weak_ptr<Observer>>::iterator Iterator;
};
void Observable::notifyservers(){
MutexLockGuard lock(mutex_);
Iterator it = observers_.begin();//Iterator的定义见第9行
while(it != observers_.end()){
shared_ptr<Observer>obj(it->lock());//尝试提升,这一步是线程安全的
if(obj){
//提升成功,现在引用计数值至少为2(想想为什么?)
obj->update();//没有竞态条件,因为obj在栈上,对象不可能在本作用域内销毁
++it;
} else {
//对象已经销毁,从容器中拿掉weak_ptr
it = observers_.erase(it);
}
}
}
1.10 再论shared_ptr的线程安全
注意:以下是shared_ptr对象本身的线程安全级别,不是它管理的对象的线程安全级别。
- 一个shared_ptr对象实体可被多个线程同时读取;
- 两个shared_ptr对象实体可以被两个线程同时写入,“析构”算写操作;
- 如果要从多个线程读写同一个shared_ptr对象,那么需要加锁。
要在多个线程中同时访问同一个shared_ptr,正确的做法是用mutex保护:
MutexLock mutex;//No need for ReaderWriterLock
shared_ptr<Foo> globalPtr;
//我们的任务是把globalPtr安全地传给doit()
void doit(const shared_ptr<Foo>& pFoo);
//globalPtr能被多个线程看到,那么它地读写需要加锁。注意我们不必用读写锁,而只用最简单地互斥锁,这是为了性能考虑。因为临界区非常小,用互斥锁也不会阻塞并发读。
//为了拷贝glogalPtr,需要再读取它地时候加锁,即:
void read(){
shared_ptr<Foo> localPtr;
{
MutexLockGuard lock(mutex);
localPtr = globalPtr;//read globalPtr
}
//use localPtr since here,读写localPtr也无须加锁
doit(localPtr);
}
//写入地时候也要加锁:
void write(){
shared_ptr<Foo> newPtr(new Foo);//注意,对象地创建在临界区之外
{
MutexLockGuard lock(mutex);
globalPtr = newPtr;//write to globalPtr
}
//use newPtr since here,读写newPtr无须加锁
doit(newPtr);
}
注意到上面的read()或write()在临界区之外都没有再访问globalPtr,而是用了一个指向同一Foo对象的栈上shared_ptr local copy。
只要有这样的local copy存在,shared_ptr作为函数参数传递时不必复制,用reference to const作为参数类型即可。
练习:
在write()函数中,globalPtr = newPtr;这一句有可能会在临界区内销毁原来globalPtr指向的Foo对象,设法将销毁行为移出临界区。
void write(){
shared_ptr<Foo>newPtr(new Foo);//注意:对象的创建在临界区之外
shared_ptr<Foo>oldPtr;//保存原来的globalPtr的值
{
MutexLockGuard lock(mutedx);
oldPtr = globalPtr;//保存原来的globalPtr的值
globalPtr = newPtr;//将新的值赋给globalPtr
}
//在临界区外销毁原来的对象
//这样可以避免在临界区内进行耗时的销毁操作
//因为此时已经释放了互斥锁,其他线程可以继续访问globalPtr
//可以保证在临界区外进行销毁操作不会引起竞争或冲突
//在这个例子中,使用了一个局部作用域来达到这个目的
oldPtr.reset();
doit(newPtr);
}
1.11 对象池
假设有Stock类,代表每一个股票的价格。每一只股票有一个唯一的字符串标识,比如:Google的key是“NASDAQ:GOOG”,IBM是“NYSE:IBM”。Stock对象是一个主动对象,它不断获取新价格。为了节省系统资源,同一个程序里边每一只出现的股票只有一个Stock对象,如果多处用到同一只股票,那么Stock对象应该被共享。
如果某一只股票没有再在任何地方用到,其对应的Stock对象应该析构,以释放资源,这隐含了“引用计数”。
为了达到上述要求,我们可以设计一个对象池Stock Factory。它的接口很简单,根据key返Stock对象。在多线程程序中,既然对象可能被销毁,那么返回shared_ptr是合理的。自然地,我们写出如下代码**(可惜是错的)。**
//version1:questionable code
class StockFactory:boost::noncopyable{
public:
shared_ptr<Stock> get(const string& key);
private:
mutable MutexLock mutex_;
std::map<string,shared_ptr<Stock>>stocks_;
}
如果在stocks_
里找到了key,就返回stocks_[key]
;否则就新建一个Stock,并存入stocks_[key]
。
有一个问题,Stock对象永远不会被销毁,因为map里存的是shard_ptr,始终有“铁丝”绑着。或许可以存一个weak_ptr?
//version 2: 数据成员修改为 std::map<string,weak_ptr<Stock>>stocks_;
shared_ptr<Stock> StockFactory::get(const string& key){
shared_ptr<Stock> pStock;
MutexLockGuard lock(mutex_);
weak_ptr<Stock>& wkStock = stocks_[key];//如果key不存在,会默认构造一个
pStock = wkStock.lock();//尝试把“棉线”提升为“铁丝”
if(!pStock){
pStock.reset(new Stock(key));
wkStock = pStock;//这里更新了stocks_[key],注意wkStock是个引用
}
return pStock;
}
1.12 弱回调
如果对象还活着,就调用它的成员函数,否则忽略之,就像 Observable::notifyObservers() 那样,称之为“若回调”。
class StockFactory:public boost::enable_shared_from_this<StockFactory>,boost::noncopyable{
public:
shared_ptr<Stock> get(const string& key){
shared_ptr<Stock> pStock;
MutexLockGuard lock(mutex_);
weak_ptr<Stock>& wkStock = stocks_[key];
pStock = wkStock.lock();
if(!pStock){
pStock.reset(new Stock(key)),boost::bind(&StockFactory::weakDeleteCallback,boost::weak_ptr<StockFactory>(shared_from_this()),_l);
wkStock = pStock;
}
return pStock;
}
private:
static void weakDeleteCallback(const boost::weak_ptr<StockFactory>& wkFactory, Stock* stock){
shared_ptr<StockFactory>factory(wkFactory.lock());
if(factory){
factory->removeStock(stock);
}
delete stock;
}
void removeStock(Stock* stock){
if(stock){
MutexLockGuard lock(mutex_);
stocks_.erase(stock->key());
}
}
private:
mutable MutexLock mutex_;
std::map<string,weak_ptr<Stock>>stocks_;
};
//两个简单的测试
void testLongLifeFactory() {
shared_ptr<StockFactory> factory(new StockFactory);
{
shared_ptr<StockFactory> factory(new StockFactory);
{
shared_ptr<Stock> stock = factory->get("NYSE:IBM");
shared_ptr<Stock> stock2 = factory->get("NYSE:IBM");
assert(stock == stock2);
}
}
void testShortLifeFactory(){
shared_ptr<Stock> stock;
{
shared_ptr<StockFactory> factory(new StockFactory);
stock = factory->get("NYSE:IBM");
shared_ptr<Stock> stock2 = factory->get("NYSE:IBM");
assert(stock == stock2);
}
}
}
本章小结:
- 原始指针暴露给多个线程往往会造成race condition 或额外的簿记负担
- 统一用shared_ptr/scoped_ptr来管理对象的生命期,在多线程中尤其重要。
- shared_ptr是值语意,当心意外延长对象的生命期。例如boost::bind和容器都可能拷贝shared_ptr。
- weak_ptr是shared_ptr的好搭档,可以用作弱回调、对象池等。
}
void testShortLifeFactory(){
shared_ptr stock;
{
shared_ptr factory(new StockFactory);
stock = factory->get(“NYSE:IBM”);
shared_ptr stock2 = factory->get(“NYSE:IBM”);
assert(stock == stock2);
}
}
}
### 本章小结:
- 原始指针暴露给多个线程往往会造成race condition 或额外的簿记负担
- 统一用shared_ptr/scoped_ptr来管理对象的生命期,在多线程中尤其重要。
- shared_ptr是值语意,当心意外延长对象的生命期。例如boost::bind和容器都可能拷贝shared_ptr。
- weak_ptr是shared_ptr的好搭档,可以用作弱回调、对象池等。