C++ 智能指针浅析
C++ 智能指针浅析
为了解决 C++ 中内存管理这个老大难问题,C++ 11 中提供了三种可用的智能指针。(早期标准库中还存在一种 auto_ptr,但由于设计上的缺陷,已经被 unique_ptr 取代了)
智能指针不仅能用来管理动态内存,还能用来管理其他类型的资源,比如互斥锁、数据库连接等,这种用资源管理对象来管理资源的思想被称为 RAII。
原始指针的缺陷
- 看不出来指向的是对象还是数组,也就不知道该用 delete 还是
delete[]
; - 不知道用完后是否应该销毁,也就是不包含所有权的信息;
- 不知道该用 delete 还是其他方式释放该对象;
- double free;
- dangling pointers(悬空指针),即对象已经被释放了,但指针还指向它。
shared_ptr
shared_ptr 体现的是共享的所有权(shared ownership),通俗地讲,就是这个被管理的对象可以被多个用户使用,只有所有用户都不再需要该资源时,它才可以被释放。
为了实现共享的所有权,shared_ptr 会维护一个引用计数,表示有多少 shared_ptr 指向这个被管理的对象。
简单地来说,当 shared_ptr 被拷贝时(不管是我们主动调用,还是将它作为传值参数或返回值),引用计数就会增加;当 shared_ptr 被析构时(比如离开它的作用域),引用计数就会减少。当引用计数归零时,这个被管理的对象就会被释放。
shared_ptr 中引用计数的操作是原子的,这点和 shared_ptr 的线程安全性有关。
shared_ptr 使用规范
要正确地使用 shared_ptr,需要注意一些问题:
- 不使用相同的内置指针初始化或 reset 多个 shared_ptr,原因请看后文的 shared_ptr 实现,这样操作会产生多个控制块对象,从而造成 double free;
get()
返回的原始指针只应该有一种用途,用来作为参数传递给遗留接口;- 不应该 delete
get()
返回的指针; - 不使用
get()
的返回值去初始化或 reset 另一个智能指针; - 当最后一个对应的智能指针被销毁时,
get()
返回的指针就无效了;
- 不应该 delete
- 如果使用智能指针管理资源而不是内存,应当定义自定义删除器;
- 如果将 shared_ptr 存放到容器中,之后不再需要其中的部分元素,应当调用 erase 将它们删除,不然这些元素的生命周期会被意外地延长。
在单条语句里将 new 来的指针放到智能指针里
int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);
processWidget(std::shared_ptr<Widget>(new Widget), priority());
该调用事实上由三部分组成:
- new Widget
- 调用 shared_ptr 构造函数
- 调用
priority()
1 应当发生在 2 之前,但标准并没有规定 3 要在何时发生;如果编译器以 1、3、2 这样的顺序来执行,那么如果调用 priority()
时抛出异常,那么就会出现内存泄漏。
如果将程序改为:
std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());
可以强制上面的过程以 1、2、3 的顺序发生,消除了内存泄漏的隐患。
此外,将这里改成 std::move()
效率更高,因为移动不涉及引用计数的增减。
std::shared_ptr<Widget> pw(new Widget);
processWidget(std::move(pw), priority());
避免该内存泄露问题的另一种方法是改用 make_shared()
:
processWidget(std::make_shared<Widget>(), priority());
make_shared
使用 make_shared()
的优势:
- 避免上面提到的内存泄露问题;
- 效率更高:先 new 出内存再传递给 shared_ptr,涉及到两次内存分配,一次分配被管理的对象,一次分配控制块对象;但如果改用
make_shared()
,只需要分配一次内存就足够容纳这两个对象。
使用 make_shared()
的缺陷:
- 不允许指定自定义删除器。
- 没法用
{}
初始化,make_shared()
将参数完美转发给构造函数时,默认使用()
。 - 如果类重载了
operator new
或operator delete
,它的内存分配行为可能表现出特殊的行为;- 比如 Widget 类的
operator new
和operator delete
可能只处理大小为sizeof(Widget)
的内存分配; - 但
make_shared()
不只是分配对象的动态内存,还会同时分配控制块的内存,这种情况下,就会造成麻烦。
- 比如 Widget 类的
- 由于控制块和对象放在同一块内存里,引用计数为 0 的时候,对象并不会被释放,只有等到控制块也不再需要的时候,这块内存才会被释放;
- 只要还有 weak_ptr 指向这个对象,控制块就不会被释放;
- 对于特别大的对象,这可能是问题。
unique_ptr
unique_ptr 体现的是独占/排他的所有权(exclusive ownership),通俗地讲,就是这个被管理的对象为单个用户所有,当该用户不再需要这个资源时,它就会被释放掉;同时,该用户还可以将资源的所有权移交给其他用户。
unique_ptr 的拷贝构造和赋值是删除的,提供了移动构造和赋值,因此要想将持有的独占资源交给其他 unique_ptr,往往需要显式地调用 std::move()
来触发移动操作。
UML 中的组合(或称复合,Composition)关系,就可以使用 unique_ptr 来建模,当整体被析构时,部分也会跟着析构。
unique_ptr 可以转换为 shared_ptr,因此 unique_ptr 很适合作为工厂函数的返回值,因为你不知道使用者需要的所有权语义究竟是独占的还是共享的;相反,一旦所有权交给了 shared_ptr,就没法再转换为独占的所有权了。
C++11 中没有类似于 make_shared()
的标准库函数,应当将 new 返回的指针传递给 unique_ptr 的构造函数;C++14 中加入了 make_unique()
。
unique_ptr 的大小
- 使用默认删除器的情况下,unique_ptr 内部只维护了一个指针,和原始指针大小相同
由于 unique_ptr 直接将自定义删除器存储在对象内部
- 使用函数指针作为删除器,大小为变为两倍的原始指针
- 使用函数对象作为删除器
- 无状态的函数对象,比如不捕获变量的 lambda 表达式,不影响 uniqur_ptr 的大小
- 否则,随着函数对象的大小增加而增加
为什么不捕获变量的 lambda 表达式在 unique_ptr 里不占空间?
实现上,lambda 表达式其实是个匿名类对象,如果它不捕获任何变量,就是个空的类,C++ 规定任何对象和对象成员的大小都至少是 1,即使该对象的类型是一个空的类,这就能保证相同类型的不同对象的地址总是不同的。
class Empty {};
class HoldsAnInt {
private:
int x;
Empty e;
};
由于上述规定的存在,导致 sizeof(HoldsAnInt) > sizeof(int)
但该规定存在例外,那就是这个空的类作为基类的时候,是不需要占据空间的。这种优化被称为空基类优化(empty base optimization,EBO)。
class HoldsAnInt: private Empty {
private:
int x;
};
这种情况下,sizeof(HoldsAnInt) == sizeof(int)
我们查看 unique_ptr 的源码,会发现它是这样存储指针和删除器的:
tuple<pointer, _Dp> _M_t;
gcc 的 tuple 的实现上应用 EBO 做空间压缩,所以不捕获变量的 lambda 表达式作为删除器时,unique_ptr 的空间不会变大。
对于程序员来说,用不捕获变量的 lambda 表达式做删除器是非常常见的情况,所以这是一个很有价值的优化。
auto_ptr 的缺陷
auto_ptr 是早期标准库中实现的一种智能指针,具有 unique_ptr 的部分特性,它用拷贝来模拟资源所有权的移交,拷贝 auto_ptr 会导致源 auto_ptr 被设为 null,这种行为是比较匪夷所思的。
std::auto_ptr<Invesetment> pInv2(pInv1); // pInv1 被设为 null
pInv1 = pInv2; // pInv2 被设为 null
假设将 auto_ptr 存储到容器里,并使用泛型算法对容器做操作,由于泛型算法可能将元素拷贝到局部临时对象中,会导致操作后的容器里存在大量空的 auto_ptr,因此 auto_ptr 不能和容器一起使用。
和 unique_ptr 作比较,unique_ptr 仅允许移动操作,而禁止了拷贝操作,语义更加清晰且安全。
自定义删除器
shared_ptr 和 unique_ptr 都允许自定义删除器,区别在于 unique_ptr 的自定义删除器是模板参数的一部分,而 shared_ptr 仅仅是作为构造函数的一个参数。
auto loggingDel = [](Widget *pw) // 自定义删除器
{
makeLogEntry(pw);
delete pw;
};
// 删除器类型是指针类型的一部分
std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);
// 删除器类型不是指针类型的一部分
std::shared_ptr<Widget> spw(new Widget, loggingDel);
不包含删除器类型模板参数给了 shared_ptr 更大的灵活性,比如 std::shared_ptr<Widget>
的容器中可以放删除器不同的各种 Widget 对象。
shared_ptr 的大小不会随着自定义删除器的大小而增加,因为这部分是放在它的控制块对象里;但 uniqur_ptr 是直接将删除器存到指针对象内了,因此它的大小是会随着自定义删除器的大小变化的。
智能指针和动态数组
shared_ptr 和动态数组
shared_ptr 不支持动态数组的管理,将动态数组传递给它后,它仍会使用 delete,而不是 delete[]
去释放这块内存,因此必须提供自己的删除器。
可以直接使用 default_delete
类型作为自定义删除器,这个类提供了针对数组类型的偏特化,会调用 delete[]
来释放内存。
std::shared_ptr<int> sp3(new int[10](), std::default_delete<int[]>());
同时,由于 shared_ptr 没有提供下标运算符,要访问数组中的元素,必须通过 get()
获取内置指针,用它来访问数组元素。
作为替代,可以使用 boost 提供的 boost::scoped_array
和 boost::shared_array
来管理动态数组。
C++17 的改进
支持直接用数组作为模板参数,它会正确地调用 delete[]
,且增加了下标运算符:
std::shared_ptr<int[]> sp3(new int[10]());
int i = sp3[2];
使用 unique_ptr 管理动态数组
在类型名后面加 []
,它会自动调用 delete[]
来释放内存,且可以使用下标运算符访问其中的元素。
unique_ptr<int[]> int_array(new int[10]{ 1, 2, 3 });
cout << int_array[1] << endl;
但比起使用 unique_ptr,更应该优先考虑 std::array
、std::vector
、std::string
等数据容器。
RAII
RAII 简介
Resource Acquisition Is Initialization (RAII),即获取到资源后立刻移交给资源管理对象。资源管理对象使用它们的析构函数确保资源被释放。这里说的资源可以是内存、数据库连接、互斥锁等各种形式的资源。
假设有一个 C 风格 API 的 Mutex 类:
void lock(Mutex *pm);
void unlock(Mutex *pm);
Mutex 的资源管理类(C++11 中类似的类叫 std::lock_guard
)可以定义为:
class LockGuard
{
public:
explicit LockGuard(Mutex* pm) :mutexPtr(pm) { lock(mutexPtr); }
~LockGuard() { unlock(mutexPtr); }
private:
Mutex* mutexPtr;
};
对想要写出异常安全代码的程序员来说,RAII 非常重要,因为如果在代码中显式地释放资源,就可能出现因为异常抛出而资源没能正确释放地情况,比如下面这段代码,如果 new 抛异常,mutex 就不会被解锁。
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex);
}
但如果改为用 RAII 的方式来管理资源,如果有异常被抛出,栈展开的过程中,资源管理对象就会被析构掉,从而释放掉被管理的资源。
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
用智能指针实现 RAII
C++11 后,可以通过智能指针来实现 RAII,对于共享资源,将它交给 shared_ptr,而对于独占资源,将它交给 unique_ptr,同时通过使用自定义删除器,来决定资源该怎样被释放。
shared_ptr 的实现
shared_ptr 实现上包含两个指针:一个指向被管理的对象,一个指向动态分配的控制块对象。控制块对象里包含:
- 引用计数(use_count),用来判断是否可以释放被管理的对象;
- 弱引用计数(weak_count),和 weak_ptr 的实现有关;
- 其他数据,比如自定义删除器。
模拟隐式转换行为
智能指针可以模拟指针的隐式转换行为,自动将派生类指针转换为基类指针,为了做到这点,需要为智能指针增加一个泛化拷贝构造函数:
template<typename T>
class SmartPtr {
public:
template<typename U>
SmartPtr(const SmartPtr<U>& other): heldPtr(other.get())
{
//...
}
T* get() const { return heldPtr; }
//...
private:
T *heldPtr;
};
这个泛化拷贝构造函数不是 explicit 的,以支持隐式转换;同时它的初始化列表告诉我们只有能隐式转换为 T*
类型的 U*
才能通过编译。
源码分析
下面包含一些 gcc 的源码分析,不想看代码的读者可以直接跳过本章。
gcc 中控制块对象的类叫 _Sp_counted_base
,包含两个引用计数,它们都是原子类型:
template<_Lock_policy _Lp = __default_lock_policy>
class _Sp_counted_base : public _Mutex_base<_Lp>
{
// ...
_Atomic_word _M_use_count; // #shared
_Atomic_word _M_weak_count; // #weak + (#shared != 0)
// ...
};
shared_ptr 中会保存指向 _Sp_counted_base
的指针。
_Sp_counted_base<_Lp>* _M_pi;
之前我们说过 shared_ptr 的模板参数中是不包含自定义删除器类型的,这就导致 deleter 没法保存在 shared_ptr 内部。
template< class T > class shared_ptr; // shared_ptr 的声明
template< class Y, class Deleter >
shared_ptr( Y* ptr, Deleter d ); // 带自定义删除器的构造函数
很显然,deleter 只能存放在控制块对象中。为此,_Sp_counted_base
有一个带删除器模板参数的派生类 _Sp_counted_deleter
:
template<typename _Ptr, typename _Deleter, typename _Alloc, _Lock_policy _Lp>
class _Sp_counted_deleter final : public _Sp_counted_base<_Lp>
这些类之间的关系如下图所示:
当创建 shared_ptr 时,会动态分配 _Sp_counted_deleter
对象,并将自定义删除器存到它里面。
template<typename _Ptr, typename _Deleter, typename _Alloc,
typename = typename __not_alloc_shared_tag<_Deleter>::type>
__shared_count(_Ptr __p, _Deleter __d, _Alloc __a) : _M_pi(0)
{
typedef _Sp_counted_deleter<_Ptr, _Deleter, _Alloc, _Lp> _Sp_cd_type;
__try
{
typename _Sp_cd_type::__allocator_type __a2(__a);
auto __guard = std::__allocate_guarded(__a2);
_Sp_cd_type* __mem = __guard.get();
::new (__mem) _Sp_cd_type(__p, std::move(__d), std::move(__a));
_M_pi = __mem;
__guard = nullptr;
}
__catch(...)
{
__d(__p); // Call _Deleter on __p.
__throw_exception_again;
}
}
shared_ptr
析构的时候,_M_pi->_M_release()
会被调用,(原子地)减少引用计数:
void _M_release() // nothrow
{
_GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(&_M_use_count);
if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1) {
_GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_use_count);
_M_dispose();
if (_Mutex_base<_Lp>::_S_need_barriers) {
__atomic_thread_fence(__ATOMIC_ACQ_REL);
}
_GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(&_M_weak_count);
if (__gnu_cxx::__exchange_and_add_dispatch(&_M_weak_count, -1) == 1) {
_GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_weak_count);
_M_destroy();
}
}
}
如果 use_count
减少到 0,调用虚函数 _M_dispose()
,其实调用到的是_Sp_counted_deleter
的实现,由此,自定义删除器被用来销毁被管理的对象。
如果 weak_count
减少到 0,调用 _M_destroy()
,销毁控制块对象。
virtual void _M_dispose() noexcept
{
_M_impl._M_del()(_M_impl._M_ptr);
}
virtual void _M_destroy() noexcept
{
__allocator_type __a(_M_impl._M_alloc());
__allocated_ptr<__allocator_type> __guard_ptr{ __a, this };
this->~_Sp_counted_deleter();
}
在不定义自定义删除器的情况下,如果被管理对象是是非数组类型,_M_pi
指向_Sp_counted_deleter
的默认实现 _Sp_counted_ptr
,它直接调 delete 析构被管理对象。
virtual void _M_dispose() noexcept
{
delete _M_ptr;
}
virtual void _M_destroy() noexcept
{
delete this;
}
但如果被管理对象是是数组类型,自定义删除器将被设置为 __sp_array_delete
,它调用 delete[]
来析构对象。
struct __sp_array_delete
{
template<typename _Yp> void operator() (_Yp* __p) const { delete[] __p; }
};
shared_ptr 的性能分析
- 大小是原始指针的两倍,因为它要同时持有指向被管理对象的指针和控制块对象的指针;
- 引用计数的内存必须是动态分配的,可以用 make_shared 来对这点做优化;
- 引用计数操作是原子的,原子操作比非原子操作要慢;
- 控制块对象包含继承关系,为了正确的销毁对象使用了虚函数,因此对象销毁的时候要承担虚函数带来的开销。
移动和引用计数
用一个 shared_ptr 移动构造另一个 shared_ptr 不会导致引用计数的变化,新的 shared_ptr 会直接接管老的 shared_ptr 的控制块对象,因此 shared_ptr 的移动操作要比拷贝操作高效。
weak_ptr
weak_ptr 不负责对象的生命周期管理,它指向一个由 shared_ptr 管理的对象,但不改变它的引用计数(use_count)。通过 weak_ptr 可以判它所指向的对象是否已经被销毁。有两种方法可以做这种判断:
- 尝试用
lock()
提升它为 shared_ptr,如果过期,返回的结果为空; - 用它构造一个新的 shared_ptr,如果过期,会抛
std::bad_weak_ptr
异常。
auto spw2 = wpw.lock();
std::shared_ptr<Widget> spw3(wpw);
weak_ptr 的实现
weak_ptr 的实现和 shared_ptr 的控制块对象相关。
由于 weak_ptr 想要知道 shared_ptr 管理的对象是否已经被析构了,那么最直接的手段就是让它访问 shared_ptr 的控制块对象,查看 use_count 是否已经是 0。
控制块对象的生命周期必须延续到所有 shared_ptr 和 weak_ptr 都已经析构掉,它才可以析构。所以需要一个控制块对象的引用计数来管理它的生命周期,即 weak_count。
weak_ptr 的使用场景
shared_ptr 可能悬空的情况下,都可以使用 weak_ptr 来做 shared_ptr 是否悬空的判断。比如 UML 中的聚合(aggregation)关系,一般由整体持有部分的 shared_ptr,部分持有整体的 weak_ptr,并使用升级操作,来判断整体是否还存活。
带缓存的工厂函数
假设说有一个开销很大的工厂函数,它针对不同的 ID 产生不同的产品,但该工厂函数的开销很大,我们希望能缓存下来它的产品:
std::shared_ptr<const Widget> loadWidget(WidgetID id);
我们希望当工厂函数客户端不再需要该缓存对象时,该对象可以被析构,因此我们用哈希表存储对象的 weak_ptr 来做缓存,获取对象时,先尝试升级缓存中的 weak_ptr,如果能成功,说明对象仍未被释放,可以节省调用工厂函数的开销:
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID,
std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock();
if (!objPtr) {
objPtr = loadWidget(id);
cache[id] = objPtr;
}
return objPtr;
}
观察者模式
观察者模式允许我们实现一种“发布-订阅”机制,当对象的状态发生变化时,通知正在“观察”该对象的其他对象。
观察者模式中有两种角色:subject 或 publisher 是状态会发生变化的对象;observer 或 subscriber 是在 subject 状态发生变化时要(通过调用 observer 的 update 接口)通知的对象。
如果使用裸指针来保存 observer,有可能发生的是 observer 已经被析构,但 subject 还不知道,又调用了 observer 的 update 方法,导致未定义行为。
解决方法是用 weak_ptr 保存 observer,在调用 update 前先升级为 shared_ptr,如果 observer 仍存活就调用 update,否则将它从 observer 列表中删除。
解决循环引用
在上图中,如果 B 也有指向 A 的引用,这时就不能选择 shared_ptr,因为如果有多个 shared_ptr 的引用关系形成环状,会导致这些指针的引用计数永远都不会归零,结果就是这些对象永远都不会被释放,即使程序中的其他部分已经不持有指向它们的智能指针。
如果我们使用裸指针,就可能出现 A 已经被析构了,B 还尝试调用它的方法,导致未定义行为。
解决方法:将引用链中的一个指针改为使用 weak_ptr 从而打破环状关系。
shared_ptr 的线程安全性
智能指针本身的线程安全
如果多个线程在不同的 shared_ptr 对象(即使这些对象是拷贝出来的,共享同一个对象的所有权)上不加同步地调用任何成员函数(包括拷贝和赋值),都是线程安全的。
如果多个线程不加同步地访问同一个 shared_ptr 对象,并且有线程使用了非 const 的成员函数(拷贝和赋值也是非 const 的),那么会发生数据竞争。虽然智能指针针对引用计数的操作是原子的,但是非 const 的成员函数往往涉及多个操作,这些操作组合在一起不是原子的。
被管理对象的线程安全
使用多个不同的 shared_ptr 对象访问同一个被管理对象,如果对该对象的操作本身不是线程安全的,那么即使通过智能指针访问,也会发生数据竞争,智能指针并不对被管理对象的线程安全性负责。
enable_shared_from_this
之前我们说过,当你持有一个 shared_ptr ,绝不应该再从它的原始指针(不管是 new 出来的还是通过 get()
得到的)中创建出一个新的 shared_ptr ,因为它们的引用计数是不共享的,会带来 double free 的问题。对于这点,即使是 this 指针也不例外。
struct S
{
shared_ptr<S> dangerous()
{
return shared_ptr<S>(this); // don't do this!
}
};
int main()
{
shared_ptr<S> sp1(new S);
shared_ptr<S> sp2 = sp1->dangerous();
return 0;
}
在这个例子中,智能指针 sp1 和 sp2 指向的是同一个底层对象,但 sp1 和 sp2 对此一无所知,它们各自管理自己的引用计数,因此这块内存会被释放两次。
要避免此问题,可以使用类模板 enable_shared_from_this
,以它的派生类作为模板实参,这种继承的模式被称为 curiously recurring template pattern(CRTP)。
struct S : enable_shared_from_this<S>
{
shared_ptr<S> not_dangerous()
{
return shared_from_this();
}
};
int main()
{
shared_ptr<S> sp1(new S);
shared_ptr<S> sp2 = sp1->not_dangerous();
return 0;
}
使用场景
有时候对象可能需要获得一个指向自己的 shared_ptr,以传递给 lambda 表达式或消息队列的时候,就需要调用 shared_from_this()
。
boost::asio::async_write(socket_, boost::asio::buffer(message_),
boost::bind(&tcp_connection::handle_write, shared_from_this()));
上面这个例子来自 asio 的教程,asio 服务器会为每个客户端连接动态创建一个 tcp_connection 对象(包含连接的上下文信息),并在上面执行异步操作,所以必须保证 tcp_connection 对象存活到回调函数被调用的时候。
但由于异步操作的特点,你不知道回调函数 handle_write 什么时候会被调用,所以很难直接去管理 tcp_connection 的生命周期,通常的做法是用 shared_ptr 来管理它,并且让异步操作捕获一份它的 shared_ptr(不管是通过 std::bind 还是 lambda 表达式的捕获列表),这样只要异步操作还未执行,tcp_connection 对象就不会被析构。
如果在 tcp_connection 的成员函数中要执行异步操作,就必须捕获一个指向自己的 shared_ptr,这时候就需要调用 shared_from_this()
,因此 tcp_connection 必须继承 enable_shared_from_this。
使用限制
必须通过 shared_ptr 来调用 shared_from_this()
,下面的代码是错误的:
int main()
{
S *p = new S;
shared_ptr<S> sp2 = p->not_dangerous(); // don't do this
}
此外,shared_from_this()
不能在构造函数里被调用。
参考材料
- [1] C++ Primer
- [2] Effective Modern C++
- [3] Effective C++
- [4] Linux多线程服务端编程 使用muduo C++网络库
本文来自博客园,作者:路过的摸鱼侠,转载请注明原文链接:https://www.cnblogs.com/ljx-null/p/16357131.html