- C++服务器开发精髓
- 张远龙
- 1990字
- 2021-07-23 18:22:05
1.1 C++RAII惯用法
什么是RAII?容笔者先卖个关子,给大家讲个故事。
1.1.1 版本1:最初的写法
在笔者刚学习服务器开发的时候,公司给笔者安排了一个练习:在 Windows 系统上写一个 C++程序,用该程序实现一个简单的服务,在客户端连接上来时,给客户端发一条“HelloWorld”消息后关闭连接,不用保证客户端一定能收到。
如果熟悉基础网络编程知识,那么你会觉得这很容易,因为这个程序描述的就是TCP网络通信的基本流程,其程序实现流程如下。
(1)创建socket。
(2)绑定IP地址和端口号。
(3)在该 IP 地址和端口号上启动监听,循环等待客户端连接的到来,在客户端连接成功后,向其发送一条“HelloWorld”消息,然后断开连接。
在 Windows 上使用网络通信 API 之前,需要使用 WSAStartup 函数初始化socket库;在程序结束时需要使用WSACleanup函数清理socket库。
笔者很快就将程序写出来了:
以上代码虽然满足了公司的要求,但是有些地方不太令人满意,因为代码中充斥着用于避免出错的重复资源清理逻辑:closesocket(sockSrv)和WSACleanup()。
这样的场景在实际开发过程中经常存在,例如下面这段伪代码:
我们可以将上述场景理解为“先分配资源,再进行相关操作,在任意中间步骤出错时都对相应的资源进行回收,如果中间步骤没有出错,则在资源使用完毕后对其进行回收”。上述伪代码片段中释放资源的重要性不言而喻:因为分配了堆内存,所以不释放会造成内存泄露。但是这样编写代码太容易出错了!我们必须时刻保持警惕,在任意出错的步骤中都要记得加上回收资源的代码。这样的编码方式不仅容易出错,还会导致大量的代码重复。那有没有办法解决这类问题呢?有,使用goto语句。
1.1.2 版本2:使用goto语句
还是以前面网络通信的代码为例,如果使用goto语句,则该代码可以简化如下:
使用 goto 语句后,一旦某个中间步骤出错,则跳转到统一的清理点进行资源清理操作。
但是,我们总被告知要慎用goto语句,因为它会让程序的结构变得混乱和难以维护。姑且不论这是否正确,如果不用 goto 语句,那么有没有更好的实现方式呢?有,使用do...while(0)循环。
1.1.3 版本3:使用do...while(0)循环
以上代码使用do...while(0)循环改进后如下:
以上代码利用 do...while(0)循环中的 break 特性巧妙地将资源回收操作集中到一个地方,使用 for 循环也能达到同样的效果。我们同样可以使用 do...while(0)改造上面堆内存分配与释放的示例,伪代码如下:
这是do...while(0)的一个妙用。但是,在C++中有更好的写法来代替do...while(0),即RAII惯用法。
1.1.4 版本4:使用RAII惯用法
RAII(Resource Acquisition Is Initialization,资源获取就是初始化)指资源在我们拿到时就已经初始化,一旦不再需要该资源,就可以自动释放该资源。
对于 C++来说,资源在构造函数中初始化(可以在构造函数中调用单独的初始化函数),在析构函数中释放或清理。常见的情形就是在函数调用中创建C++对象时分配资源,在 C++对象出了作用域时将其自动清理和释放(不管这个对象是如何出作用域的,不管是否因为某个中间步骤不满足条件而导致提前返回,也不管是否正常走完全部流程后返回)。
还是以上面网络通信的例子来说,初始化程序时需要分配两种资源:Windows 的socket网络库和一个用于监听的socket。首先,初始化好Windows socket网络库;然后创建一个用于监听的socket。在程序结束时,我们需要清理这两种资源。
使用RAII惯用法改进后的代码如下:
以上代码并没有在构造函数中分配资源,而是单独使用一个DoInit方法初始化资源,并在析构函数中回收相应的资源。这样在main函数中就不用担心任何中间步骤失败而忘记释放资源了,因为一旦main函数调用结束,serverSocket对象就会自动调用其析构函数回收相应的资源。这就是RAII惯用法的原理!
严格来说,以上代码中ServerSocket的成员变量m_bInit应该被设计成类静态成员,调用 WSAStartup 和 WSACleanup 的函数应该被设计成类的静态方法,因为它们只需在程序初始化和退出时各调用一次就可以了。
希望读者能理解 RAII 惯用法,因为它在 C++中太常用了。我们也可以使用 RAII 惯用法再次改写上文中分配堆内存的伪代码示例:
其中,heapObj对象一旦出了其作用域,该程序就会自动调用其析构函数释放堆内存。当然,RAII 惯用法中对资源分配和释放的定义可以延伸出各种外延和内涵,例如对多线程锁的获取和释放。我们在实际开发中也常常遇到以下情形:
这是一段很常见的逻辑:为了避免死锁,我们必须在每个可能退出的分支上都释放锁。随着逻辑写得越来越复杂,我们忘记在某个退出的分支上释放锁的可能性也越来越大。而RAII惯用法正好解决了这个问题:我们可以将锁包裹成一个对象,在构造函数中获取锁,在析构函数中释放锁。伪代码如下:
使用 RAII 惯用法之后,我们就再也不必在每个函数出口处都加上释放锁的代码了,因为在函数调用结束后会自动释放锁。
对于以上代码,有经验的读者可能一眼就看出来了:这不就是C++11中std::lock_guard和 boost 库中 boost::mutex::scoped_lock 的实现原理吗?确实是,本书后续章节会详细介绍操作系统和C++11提供的各类锁的用法。
1.1.5 小结
资源泄露和死锁等问题具有非常强的隐蔽性,如果在生产环境中出现这些问题,则难以复现、排查和定位问题。理解并熟练使用RAII惯用法不仅能让我们的代码更加简洁和模块化,也能让我们在开发阶段避免一部分资源泄漏和死锁问题。