Linux多线程服务端编程:线程安全的对象管理

1. 前置知识

1.1 __builtin_expect

1.1.1 使用

__builtin_expect提供给编译器分支预测优化信息,其含义为 exp 大概率为 c,其返回值为 exp 的值;

long __builtin_expect(long exp, long c)
// 下述表明该分支大概率不会执行
if (__builtin_expect(t_cachedTid == 0, 0))
{
  func();
}
// C++20 正式将其变为关键字,之前的宏如下
// 两次取非,可以保证最后一定为 bool 值
#define likely(x)     __builtin_expect(!!(x), 1)
#define unlikely(x)   __builtin_expect(!!(x), 0)

使用这个优化手段时,必须要保证概率差别真大很多(默认要有 90% 的把握),在 muduo 库中用在缓存线程 tid 场景;

1.1.2 原理
// -fprofile-arcs 必须要加上才会在汇编代码中体现出来区分
gcc -fprofile-arcs -O2 myexpect.cpp -o mexp
objdump -S mexp

__builtin_expect 在汇编代码中体现是,把概率更大的分支与之后的代码直接放在一起,概率小的分支需要进行跳转;

if (likely(a == 2))
   a++;
else
   a--;

如下图红线为概率小的分支,蓝线为概率大的分支;(这样的好处是 CPU 流水线可以大概率顺利执行,从而提高效率)

Linux多线程服务端编程:线程安全的对象管理_第1张图片

1.1.3 参考

https://kernelnewbies.org/FAQ/LikelyUnlikely
https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html

1.2 __thread

(1)GCC 内置的线程局部存储,类似全局变量,实现非常高效,存取效率与全局变量也类似,但有所不同;

pthread_key_t 快很多,使用了段寄存器
每个线程都拥有该变量的一份拷贝,且互不干扰。
线程局部存储中的变量将一直存在,直至线程终止,当线程终止时会自动释放这一存储;
只能存储一些小变量(POD类型,基本为内置类型);不能修饰 class 类型,因为无法自动调用构造和析构函数;
可用于修饰全局变量、函数内的静态变量,不能修饰函数的局部变量或 class 的普通成员变量;
__thread 变量的初始化只能用编译器常量;

(2)C++11 新引入了 thread_local 关键字,其与之作用相同;

1.3 const 成员函数

(1)一般情况下 const 对象调用 const 成员函数,普通对象调用非 const 成员函数
const 对象不可用调用非 const 成员函数。普通对象可以调用 const 成员函数

(2)const 成员函数中可以修改静态数据成员,不可以修改非静态成员(此处的理解是,从语义方面来讲,静态成员变量类似于全局变量,不属于某个对象,因此不算是对对象进行了修改。)

(3)对于声明时加了mutable(只能修饰非静态非const)的变量,也可以在const成员函数中改变。

1.4 Observer 模式

Observable 维护了一个指针数组,每个元素指向一个 Observer ,调用 notifyObservers 调用各个 Observer 的 update 方法;

1.8 节中 Observer 对象也是由 shared_ptr 管理的;

1.5 MutexLockGuard

MutexLock 封装了一个互斥锁和一个线程 ID 变量,从而可以判断当前线程是否持有该锁;
MutexLockGuard 对上述进一步封装,构造时调用 lock,析构时调用unlock

2. 线程安全的对象生命期管理

2.1 对象的构造

要做到线程安全,唯一的要求是在构造期间不要泄漏 this 指针;

不要在构造函数中注册任何回调;
不要在构造函数中把 this 传给跨线程的对象;(构造期间对象没有完成初始化,其他线程访问时可能是半成品,造成难以预料的后果)
即使在构造函数的最后一行也不行;(因为该类可能是基类,之后要去执行子类的构造函数)

2.2 对象的析构

(1)一个对象可被多线程观察到,线程 A 调用析构函数,线程 B 调用该对象其他函数,就可能会导致意想不到的后果;

线程 A 可能获取到锁先执行,线程 B 阻塞在锁处;但析构函数会把锁也释放掉;

(2)只要别的线程都访问不到这个对象时,析构才是安全的;

(3)一个函数如果要锁住相同类型的多个对象,为保证始终按相同顺序加锁(避免潜在死锁,例如线程 A 调用 swap(a,b),线程 B 调用 swap(b,a)),可以先比较 mutex 对象的地址,始终先加锁地址较小的 mutex;

2.3 shared_ptr 的使用

使用 shared_ptr 来管理对象

2.3.1 weak_ptr

可以判断对象是否还存活;如果对象存活,可以提升(lock())为 shared_ptr,否则,返回一个空的 shared_ptr,提升lock()行为是线程安全的

2.3.2 enable_shared_from_this

直接从原始指针构造的各 shared_ptr 不会共享引用计数,这就可能会导致重复释放问题;

enable_shared_from_this 使得从 当前被 pt 管理的对象 t 安全的生成其他 shared_ptr 实例(这些都共享 t 的所有权)

2.3.3 swap 技巧
void write()
{
	shared_ptr<Foo> newPtr(new Foo); // 注意,对象的创建在临界区之外
	{
		MutexLockGuard lock(mutex);
		globalPtr = newPtr; // write to globalPtr 
	}
	// use newPtr since here,读写newPtr 无须加锁
	doit(newPtr);
}

上述代码中原先 globalPtr 指向对象的析构可能会发生在临界区,可以利用 swap 技巧将其推迟到临界区之外;

void write()
{
	shared_ptr<Foo> newPtr(new Foo); // 注意,对象的创建在临界区之外
	shared_ptr<Foo> newPtr1(newPtr);
	{
		MutexLockGuard lock(mutex);
		swap(newPtr1, globalPtr); // write to globalPtr 
	}
	// use newPtr since here,读写newPtr 无须加锁
	doit(newPtr);
}
2.3.4 注意事项

(1)意外延长对象的生命期

boost::bind 会把实参拷贝一份,如果参数是个 shared_ptr,那么对象的生命期就不会短于 boost::function 对象

class Foo
{
	void doit();
};
shared_ptr<Foo> pFoo(new Foo);
boost::function<void()> func = boost::bind(&Foo::doit, pFoo); // long life foo 这里 func 持有 shared_ptr 的一份拷贝

(2)析构动作在创建时捕获;

shard_ptr<T> ptr( new T1 ); 

注意,构造 shard_ptr 时,使用 模板类型推导 保存了 T1 的类型(而非 T 的类型,因为 T 可能是 T1 的基类),从而可以调用其相应析构函数(即使基类的析构函数不是虚函数,详见 STL源码分析:shared_ptr 和 weak_ptr;

shared_ptr 可以持有任何对象,也能保证安全的释放;

(3)如果对象的析构比较耗时,可以使用一个单独的线程专门做析构

可以使用一个 BlockingQueue >

2.3.5 对象池

(1)只允许出现一个 T 对象,多线程同时访问同一个对象时,其应该被共享;

自然的想法是使用 shared_ptr 管理对象,但引用计数变为 0 时,虽然可以释放对象 T ,但无法减小哈希表的大小;

std::map<string, weak_ptr<T>> mt_;

(2)解决办法是:使用智能指针的定制析构功能,在析构 T 对象时同时清理哈希表;

class StockFactory : boost::noncopyable
{
	// 在get() 中,将pStock.reset(new Stock(key)); 改为:
	// pStock.reset(new Stock(key),
	//				boost::bind(&StockFactory::deleteStock, this, _1)); // ***
private:
	void deleteStock(Stock* stock)
	{
		if (stock) {
			MutexLockGuard lock(mutex_);
			stocks_.erase(stock->key());
		}
		delete stock; // sorry, I lied
	}
.......
};

bind 中使用到了 this 指针这种做法要求 StockFactory 不能先于 Stock 对象析构,否则会 core dump;

(3)解决办法是:万能的 shared_ptr,将 StockFactory 继承enable_shared_from_thisbind绑定 shared_from_this(),可以保证调用 deleteStockStockFactory 还存活;但这种做法延长了 StockFactory 的生命期;

(4)弱回调:如果对象还活着就调用它的成员函数,否则就忽略;

判断对象活着的方法很容易想到是 weak_ptr

这种做法的好处是,不会延长对象的生命期;

pStock.reset(new Stock(key), 
			 boost::bind(&StockFactory::weakDeleteCallback, 
						  boost::weak_ptr<StockFactory>(shared_from_this()), _1));

3. 线程同步

只用非递归的 mutex (即不可重入的 mutex)

3.1 只用非递归的 mutex

在使用迭代器遍历时修改 vector,有两种做法:把修改推迟,等循环结束再修改;用 copy-on-write;

在调试时,可以加上在函数后 __attribute__((noinline)) ,避免防止函数 inline 展开;

3.2 同步手段

(1)broadcast 通常用于表示状态变化,signal 通常用于表示资源可用;

(2)读写锁中,reader lock 是可重入的,writer lock 是不可重入的;为防止 writer lock 饥饿,其通常会阻塞后来的 reader lock ,因此 reader lock 在重入时可能死锁;

(3)assert 在 release build 里是空语句

(4)单例模式下,指令重排序可能会导致 Double-Checked Locking 失效;

正常顺序为:分配空间,使用给定参数构造对象,将对象赋值给变量;

可能的顺序为:分配空间,将该空间地址赋值给指针,使用给定参数构造对象;

此时第 2 步中,其他线程可以判断出指针不为空,但只能看到拥有默认值的对象,可能会导致问题;这在 C++ 中是合法的,可以通过加入显式内存屏障阻止重新排序;

使用了 pthread_once

C++11 可以使用局部静态变量,其初始化是线程安全的;

(5)2.8 节练习题

错误一直接修改了全局变量,可能会导致 traverse 中迭代器失效情况;错误二、三都可能发生丢失更新的情况,即两个线程同时拿到了原先的 oldFoos,同时向里面加入对象;

你可能感兴趣的:(计算机网络,linux,服务器,c++)