Boost C++ 库学习手册(六)

原文:zh.annas-archive.org/md5/9ADEA77D24CFF2D20B546F835360FD23

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:使用 Boost Asio 进行网络编程

在今天的网络世界中,处理每秒数千个请求的互联网服务器有一个艰巨的任务要完成——保持响应性,并且即使请求量增加也不会减慢。构建可靠的进程,有效地处理网络 I/O 并随着连接数量的增加而扩展,是具有挑战性的,因为它通常需要应用程序员理解底层协议栈并以巧妙的方式利用它。增加挑战的是跨平台的网络编程接口和模型的差异,以及使用低级 API 的固有困难。

Boost Asio(发音为 ay-see-oh)是一个可移植的库,用于使用一致的编程模型执行高效的网络 I/O。重点是执行异步 I/O(因此称为 Asio),其中程序启动 I/O 操作并继续执行其他任务,而不会阻塞等待操作系统返回操作结果。当底层操作系统完成操作时,Asio 库会通知程序并采取适当的操作。Asio 帮助解决的问题以及它使用的一致、可移植接口使其非常有用。但是,交互的异步性质也使其更加复杂和不那么直观。这就是为什么我们将分两部分学习 Asio 的原因:首先理解其交互模型,然后使用它执行网络 I/O:

  • 使用 Asio 进行任务执行

  • 使用 Asio 进行网络编程

Asio 提供了一个工具包,用于执行和管理任意任务,本章的第一部分重点是理解这个工具包。我们在本章的第二部分应用这种理解,当我们具体看一下 Asio 如何帮助编写使用互联网协议(IP)套件的程序与其他程序进行网络通信时。

使用 Asio 进行任务执行

在其核心,Boost Asio 提供了一个任务执行框架,您可以使用它来执行任何类型的操作。您将您的任务创建为函数对象,并将它们发布到 Boost Asio 维护的任务队列中。您可以注册一个或多个线程来选择这些任务(函数对象)并调用它们。线程不断地选择任务,直到任务队列为空,此时线程不会阻塞,而是退出。

IO 服务、队列和处理程序

Asio 的核心是类型boost::asio::io_service。程序使用io_service接口执行网络 I/O 和管理任务。任何想要使用 Asio 库的程序都会创建至少一个io_service实例,有时甚至会创建多个。在本节中,我们将探索io_service的任务管理能力,并将网络 I/O 的讨论推迟到本章的后半部分。

以下是 IO 服务在使用强制性的“hello world”示例:

清单 11.1:Asio Hello World

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main() {
 6   asio::io_service service;
 7
 8   service.post(
 9     [] {
10       std::cout << "Hello, world!" << '\n';
11     });
12
13   std::cout << "Greetings: \n";
14   service.run();
15 }

我们包括方便的头文件boost/asio.hpp,其中包括本章示例中需要的大部分 Asio 库(第 1 行)。Asio 库的所有部分都在命名空间boost::asio下,因此我们为此使用一个更短的别名(第 3 行)。程序本身只是在控制台上打印Hello, world!,但是通过一个任务来实现。

程序首先创建了一个io_service的实例(第 6 行),并使用io_servicepost成员函数将一个函数对象发布到其中。在这种情况下,使用 lambda 表达式定义的函数对象被称为处理程序。对post的调用将处理程序添加到io_service内部的队列中;一些线程(包括发布处理程序的线程)必须分派它们,即,将它们从队列中移除并调用它们。对io_servicerun成员函数的调用(第 14 行)正是这样做的。它循环遍历io_service内部的处理程序,移除并调用每个处理程序。实际上,我们可以在调用run之前向io_service发布更多的处理程序,并且它会调用所有发布的处理程序。如果我们没有调用run,则不会分派任何处理程序。run函数会阻塞,直到队列中的所有处理程序都被分派,并且只有在队列为空时才会返回。单独地,处理程序可以被视为独立的、打包的任务,并且 Boost Asio 提供了一个很好的机制来分派任意任务作为处理程序。请注意,处理程序必须是无参数的函数对象,也就是说,它们不应该带有参数。

注意

默认情况下,Asio 是一个仅包含头文件的库,但使用 Asio 的程序需要至少链接boost_system。在 Linux 上,我们可以使用以下命令行构建这个示例:

$ g++ -g listing11_1.cpp -o listing11_1 -lboost_system -std=c++11

本章中的大多数示例都需要您链接到其他库。您可以使用以下命令行构建本章中的所有示例:

$ g++ -g listing11_25.cpp -o listing11_25 -lboost_system -lboost_coroutine -lboost_date_time -std=c++11

如果您没有从本机包安装 Boost,并且需要在 Windows 上安装,请参考第一章介绍 Boost

运行此程序会打印以下内容:

Greetings: Hello, World!

请注意,在调用run(第 14 行)之前,Greetings:是从主函数(第 13 行)打印出来的。调用run最终会分派队列中的唯一处理程序,打印出Hello, World!。多个线程也可以调用相同的 I/O 对象上的run并发地分派处理程序。我们将在下一节中看到这如何有用。

处理程序状态 - run_one、poll 和 poll_one

虽然run函数会阻塞,直到队列中没有更多的处理程序,但io_service还有其他成员函数,让您以更大的灵活性处理处理程序。但在我们查看这个函数之前,我们需要区分挂起和准备好的处理程序。

我们发布到io_service的处理程序都准备立即运行,并在它们在队列上轮到时立即被调用。一般来说,处理程序与在底层操作系统中运行的后台任务相关联,例如网络 I/O 任务。这样的处理程序只有在关联的任务完成后才会被调用,这就是为什么在这种情况下,它们被称为完成处理程序。这些处理程序被称为挂起,直到关联的任务等待完成,一旦关联的任务完成,它们就被称为准备

run不同,poll成员函数会分派所有准备好的处理程序,但不会等待任何挂起的处理程序准备就绪。因此,如果没有准备好的处理程序,它会立即返回,即使有挂起的处理程序。poll_one成员函数如果有一个准备好的处理程序,会分派一个,但不会阻塞等待挂起的处理程序准备就绪。

run_one成员函数会在非空队列上阻塞,等待处理程序准备就绪。如果在空队列上调用它,它会返回,并且在找到并分派一个准备好的处理程序后立即返回。

发布与分派

post成员函数的调用会将处理程序添加到任务队列并立即返回。稍后对run的调用负责调度处理程序。还有另一个名为dispatch的成员函数,可以用来请求io_service立即调用处理程序。如果在已经调用了runpollrun_onepoll_one的线程中调用了dispatch,那么处理程序将立即被调用。如果没有这样的线程可用,dispatch会将处理程序添加到队列并像post一样立即返回。在以下示例中,我们从main函数和另一个处理程序中调用dispatch

清单 11.2:post 与 dispatch

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main() {
 6   asio::io_service service;
 7   // Hello Handler – dispatch behaves like post
 8   service.dispatch([]() { std::cout << "Hello\n"; });
 9
10   service.post(
11     [&service] { // English Handler
12       std::cout << "Hello, world!\n";
13       service.dispatch([] {  // Spanish Handler, immediate
14                          std::cout << "Hola, mundo!\n";
15                        });
16     });
17   // German Handler
18   service.post([&service] {std::cout << "Hallo, Welt!\n"; });
19   service.run();
20 }

运行此代码会产生以下输出:

Hello
Hello, world!
Hola, mundo!
Hallo, Welt!

dispatch的第一次调用(第 8 行)将处理程序添加到队列中而不调用它,因为io_service上尚未调用run。我们称这个为 Hello 处理程序,因为它打印Hello。然后是两次对post的调用(第 10 行,第 18 行),它们分别添加了两个处理程序。这两个处理程序中的第一个打印Hello, world!(第 12 行),然后调用dispatch(第 13 行)添加另一个打印西班牙问候语Hola, mundo!(第 14 行)的处理程序。这两个处理程序中的第二个打印德国问候语Hallo, Welt(第 18 行)。为了方便起见,让我们称它们为英文、西班牙文和德文处理程序。这在队列中创建了以下条目:

Hello Handler
English Handler
German Handler

现在,当我们在io_service上调用run(第 19 行)时,首先调度 Hello 处理程序并打印Hello。然后是英文处理程序,它打印Hello, World!并在io_service上调用dispatch,传递西班牙处理程序。由于这在已经调用run的线程的上下文中执行,对dispatch的调用会调用西班牙处理程序,打印Hola, mundo!。随后,德国处理程序被调度打印Hallo, Welt!,在run返回之前。

如果英文处理程序调用post而不是dispatch(第 13 行),那么西班牙处理程序将不会立即被调用,而是在德国处理程序之后排队。德国问候语Hallo, Welt!将在西班牙问候语Hola, mundo!之前出现。输出将如下所示:

Hello
Hello, world!
Hallo, Welt!
Hola, mundo!

通过线程池并发执行

io_service对象是线程安全的,多个线程可以同时在其上调用run。如果队列中有多个处理程序,它们可以被这些线程同时处理。实际上,调用run的一组线程在给定的io_service上形成一个线程池。后续的处理程序可以由池中的不同线程处理。哪个线程调度给定的处理程序是不确定的,因此处理程序代码不应该做出任何这样的假设。在以下示例中,我们将一堆处理程序发布到io_service,然后启动四个线程,它们都在其上调用run

清单 11.3:简单线程池

 1 #include <boost/asio.hpp>
 2 #include <boost/thread.hpp>
 3 #include <boost/date_time.hpp>
 4 #include <iostream>
 5 namespace asio = boost::asio;
 6
 7 #define PRINT_ARGS(msg) do {\
 8   boost::lock_guard<boost::mutex> lg(mtx); \
 9   std::cout << '[' << boost::this_thread::get_id() \
10             << "] " << msg << std::endl; \
11 } while (0)
12
13 int main() {
14   asio::io_service service;
15   boost::mutex mtx;
16
17   for (int i = 0; i < 20; ++i) {
18     service.post([i, &mtx]() { 
19                          PRINT_ARGS("Handler[" << i << "]");
20                          boost::this_thread::sleep(
21                               boost::posix_time::seconds(1));
22                        });
23   }
24
25   boost::thread_group pool;
26   for (int i = 0; i < 4; ++i) {
27     pool.create_thread([&service]() { service.run(); });
28   }
29
30   pool.join_all();
31 }

我们在循环中发布了 20 个处理程序(第 18 行)。每个处理程序打印其标识符(第 19 行),然后休眠一秒钟(第 19-20 行)。为了运行处理程序,我们创建了一个包含四个线程的组,每个线程在io_service上调用 run(第 21 行),并等待所有线程完成(第 24 行)。我们定义了宏PRINT_ARGS,它以线程安全的方式将输出写入控制台,并标记当前线程 ID(第 7-10 行)。我们以后也会在其他示例中使用这个宏。

要构建此示例,您还必须链接libboost_threadlibboost_date_time,在 Posix 环境中还必须链接libpthread

$ g++ -g listing9_3.cpp -o listing9_3 -lboost_system -lboost_thread -lboost_date_time -pthread -std=c++11

在我的笔记本电脑上运行此程序的一个特定运行产生了以下输出(有些行被剪掉):

[b5c15b40] Handler[0]
[b6416b40] Handler[1]
[b6c17b40] Handler[2]
[b7418b40] Handler[3]
[b5c15b40] Handler[4]
[b6416b40] Handler[5][b6c17b40] Handler[13]
[b7418b40] Handler[14]
[b6416b40] Handler[15]
[b5c15b40] Handler[16]
[b6c17b40] Handler[17]
[b7418b40] Handler[18]
[b6416b40] Handler[19]

您可以看到不同的处理程序由不同的线程执行(每个线程 ID 标记不同)。

提示

如果任何处理程序抛出异常,它将传播到执行处理程序的线程上对run函数的调用。

io_service::work

有时,即使没有处理程序要调度,保持线程池启动也是有用的。runrun_one都不会在空队列上阻塞。因此,为了让它们阻塞等待任务,我们必须以某种方式指示有未完成的工作要执行。我们通过创建io_service::work的实例来实现这一点,如下例所示:

11.4 节:使用 io_service::work 保持线程忙碌

 1 #include <boost/asio.hpp>
 2 #include <memory>
 3 #include <boost/thread.hpp>
 4 #include <iostream>
 5 namespace asio = boost::asio;
 6
 7 typedef std::unique_ptr<asio::io_service::work> work_ptr;
 8
 9 #define PRINT_ARGS(msg) do {\ … 
...
14
15 int main() {
16   asio::io_service service;
17   // keep the workers occupied
18   work_ptr work(new asio::io_service::work(service));
19   boost::mutex mtx;
20
21   // set up the worker threads in a thread group
22   boost::thread_group workers;
23   for (int i = 0; i < 3; ++i) {
24     workers.create_thread([&service, &mtx]() {
25                          PRINT_ARGS("Starting worker thread ");
26                          service.run();
27                          PRINT_ARGS("Worker thread done");
28                        });
29   }
30
31   // Post work
32   for (int i = 0; i < 20; ++i) {
33     service.post(
34       [&service, &mtx]() {
35         PRINT_ARGS("Hello, world!");
36         service.post([&mtx]() {
37                            PRINT_ARGS("Hola, mundo!");
38                          });
39       });
40   }
41
42   work.reset(); // destroy work object: signals end of work
43   workers.join_all(); // wait for all worker threads to finish
44 }

在这个例子中,我们创建了一个包装在unique_ptr中的io_service::work对象(第 18 行)。我们通过将io_service对象的引用传递给work构造函数,将其与io_service对象关联起来。请注意,与 11.3 节不同,我们首先创建了工作线程(第 24-27 行),然后发布了处理程序(第 33-39 行)。然而,由于调用run阻塞(第 26 行),工作线程会一直等待处理程序。这是因为我们创建的io_service::work对象指示io_service队列中有未完成的工作。因此,即使所有处理程序都被调度,线程也不会退出。通过在包装work对象的unique_ptr上调用reset,其析构函数被调用,通知io_service所有未完成的工作已完成(第 42 行)。线程中的run调用返回,一旦所有线程都加入,程序就会退出(第 43 行)。我们将work对象包装在unique_ptr中,以便在程序的适当位置以异常安全的方式销毁它。

我们在这里省略了PRINT_ARGS的定义,请参考 11.3 节。

通过 strands 进行序列化和有序执行

线程池允许处理程序并发运行。这意味着访问共享资源的处理程序需要同步访问这些资源。我们在 11.3 和 11.4 节中已经看到了这方面的例子,当我们同步访问全局对象std::cout时。作为在处理程序中编写同步代码的替代方案,我们可以使用strands

提示

将 strand 视为任务队列的子序列,其中没有两个来自同一 strand 的处理程序会同时运行。

队列中的其他处理程序的调度不受 strand 的影响。让我们看一个使用 strands 的例子:

11.5 节:使用 strands

 1 #include <boost/asio.hpp>
 2 #include <boost/thread.hpp>
 3 #include <boost/date_time.hpp>
 4 #include <cstdlib>
 5 #include <iostream>
 6 #include <ctime>
 7 namespace asio = boost::asio;
 8 #define PRINT_ARGS(msg) do {\
...
13
14 int main() {
15   std::srand(std::time(0));
16   asio::io_service service;
17   asio::io_service::strand strand(service);
18   boost::mutex mtx;
19   size_t regular = 0, on_strand = 0;
20 
21  auto workFuncStrand = [&mtx, &on_strand] {
22           ++on_strand;
23           PRINT_ARGS(on_strand << ". Hello, from strand!");
24           boost::this_thread::sleep(
25                       boost::posix_time::seconds(2));
26         };
27
28   auto workFunc = [&mtx, &regular] {
29                   PRINT_ARGS(++regular << ". Hello, world!");
30                   boost::this_thread::sleep(
31                         boost::posix_time::seconds(2));
32                 };
33   // Post work
34   for (int i = 0; i < 15; ++i) {
35     if (rand() % 2 == 0) {
36       service.post(strand.wrap(workFuncStrand));
37     } else {
38       service.post(workFunc);
39     }
40   }
41
42   // set up the worker threads in a thread group
43   boost::thread_group workers;
44   for (int i = 0; i < 3; ++i) {
45     workers.create_thread([&service, &mtx]() {
46                        PRINT_ARGS("Starting worker thread ");
47                       service.run();
48                        PRINT_ARGS("Worker thread done");
49                     });
50   }
51
52   workers.join_all(); // wait for all worker threads to finish
53 }

在这个例子中,我们创建了两个处理程序函数:workFuncStrand(第 21 行)和workFunc(第 28 行)。lambda workFuncStrand捕获一个计数器on_strand,递增它,并打印一个带有计数器值前缀的消息Hello, from strand!。函数workFunc捕获另一个计数器regular,递增它,并打印带有计数器前缀的消息Hello, World!。两者在返回前暂停 2 秒。

要定义和使用 strand,我们首先创建一个与io_service实例关联的io_service::strand对象(第 17 行)。然后,我们通过使用strandwrap成员函数(第 36 行)将所有要成为该 strand 一部分的处理程序发布。或者,我们可以直接使用 strand 的postdispatch成员函数发布处理程序到 strand,如下面的代码片段所示:

33   for (int i = 0; i < 15; ++i) {
34     if (rand() % 2 == 0) {
35       strand.post(workFuncStrand);
37     } else {
...

strand 的wrap成员函数返回一个函数对象,该函数对象调用 strand 上的dispatch来调用原始处理程序。最初,添加到队列中的是这个函数对象,而不是我们的原始处理程序。当得到适当的调度时,这将调用原始处理程序。对于这些包装处理程序的调度顺序没有约束,因此,原始处理程序被调用的实际顺序可能与它们被包装和发布的顺序不同。

另一方面,在线程上直接调用postdispatch可以避免中间处理程序。直接向线程发布也可以保证处理程序将按照发布的顺序进行分发,实现线程中处理程序的确定性排序。stranddispatch成员会阻塞,直到处理程序被分发。post成员只是将其添加到线程并返回。

请注意,workFuncStrand在没有同步的情况下递增on_strand(第 22 行),而workFuncPRINT_ARGS宏(第 29 行)中递增计数器regular,这确保递增发生在临界区内。workFuncStrand处理程序被发布到一个线程中,因此可以保证被序列化;因此不需要显式同步。另一方面,整个函数通过线程串行化,无法同步较小的代码块。在线程上运行的处理程序和其他处理程序之间没有串行化;因此,对全局对象的访问,如std::cout,仍然必须同步。

运行上述代码的示例输出如下:

[b73b6b40] Starting worker thread 
[b73b6b40] 0\. Hello, world from strand!
[b6bb5b40] Starting worker thread 
[b6bb5b40] 1\. Hello, world!
[b63b4b40] Starting worker thread 
[b63b4b40] 2\. Hello, world!
[b73b6b40] 3\. Hello, world from strand!
[b6bb5b40] 5\. Hello, world!
[b63b4b40] 6\. Hello, world![b6bb5b40] 14\. Hello, world!
[b63b4b40] 4\. Hello, world from strand!
[b63b4b40] 8\. Hello, world from strand!
[b63b4b40] 10\. Hello, world from strand!
[b63b4b40] 13\. Hello, world from strand!
[b6bb5b40] Worker thread done
[b73b6b40] Worker thread done
[b63b4b40] Worker thread done

线程池中有三个不同的线程,并且线程中的处理程序由这三个线程中的两个选择:最初由线程 IDb73b6b40选择,后来由线程 IDb63b4b40选择。这也消除了一个常见的误解,即所有线程中的处理程序都由同一个线程分发,这显然不是这样。

提示

同一线程中的不同处理程序可能由不同的线程分发,但永远不会同时运行。

使用 Asio 进行网络 I/O

我们希望使用 Asio 构建可扩展的网络服务,执行网络 I/O。这些服务接收来自远程机器上运行的客户端的请求,并通过网络向它们发送信息。跨机器边界的进程之间的数据传输,通过网络进行,使用某些网络通信协议。其中最普遍的协议是 IP 或Internet Protocol及其上层的一套协议。Boost Asio 支持 TCP、UDP 和 ICMP,这三种流行的 IP 协议套件中的协议。本书不涵盖 ICMP。

UDP 和 TCP

用户数据报协议或 UDP 用于在 IP 网络上从一个主机向另一个主机传输数据报或消息单元。UDP 是一个基于 IP 的非常基本的协议,它是无状态的,即在多个网络 I/O 操作之间不维护任何上下文。使用 UDP 进行数据传输的可靠性取决于底层网络的可靠性,UDP 传输具有以下注意事项:

  • UDP 数据报可能根本不会被传递

  • 给定的数据报可能会被传递多次

  • 两个数据报可能不会按照从源发送到目的地的顺序被传递

  • UDP 将检测数据报的任何数据损坏,并丢弃这样的消息,没有任何恢复的手段

因此,UDP 被认为是一种不可靠的协议。

如果应用程序需要协议提供更强的保证,我们选择传输控制协议或 TCP。TCP 使用字节流而不是消息进行处理。它在网络通信的两个端点之间使用握手机制建立持久的连接,并在连接的生命周期内维护状态。两个端点之间的所有通信都发生在这样的连接上。以比 UDP 略高的延迟为代价,TCP 提供以下保证:

  • 在给定的连接上,接收应用程序按照发送顺序接收发送方发送的字节流

  • 在传输过程中丢失或损坏的数据可以重新传输,大大提高了交付的可靠性

可以自行处理不可靠性和数据丢失的实时应用通常使用 UDP。此外,许多高级协议都是在 UDP 之上运行的。TCP 更常用,其中正确性关注超过实时性能,例如电子邮件和文件传输协议,HTTP 等。

IP 地址

IP 地址是用于唯一标识连接到 IP 网络的接口的数字标识符。较旧的 IPv4 协议在 4 十亿(2³²)地址的地址空间中使用 32 位 IP 地址。新兴的 IPv6 协议在 3.4 × 10³⁸(2¹²⁸)个唯一地址的地址空间中使用 128 位 IP 地址,这几乎是不可枯竭的。您可以使用类boost::asio::ip::address表示两种类型的 IP 地址,而特定版本的地址可以使用boost::asio::ip::address_v4boost::asio::ip::address_v6表示。

IPv4 地址

熟悉的 IPv4 地址,例如 212.54.84.93,是以点分四进制表示法表示的 32 位无符号整数;四个 8 位无符号整数或八位字节表示地址中的四个字节,从左到右依次是最重要的,以点(句号)分隔。每个八位字节的范围是从 0 到 255。IP 地址通常以网络字节顺序解释,即大端序。

子网

较大的计算机网络通常被划分为称为子网的逻辑部分。子网由一组可以使用广播消息相互通信的节点组成。子网有一个关联的 IP 地址池,具有一个共同的前缀,通常称为路由前缀网络地址。IP 地址字段的剩余部分称为主机部分

给定 IP 地址前缀长度,我们可以使用子网掩码计算前缀。子网的子网掩码是一个 4 字节的位掩码,与子网中的 IP 地址进行按位与运算得到路由前缀。对于具有长度为 N 的路由前缀的子网,子网掩码的最高有效 N 位设置,剩余的 32-N 位未设置。子网掩码通常以点分四进制表示法表示。例如,如果地址 172.31.198.12 具有长度为 16 位的路由前缀,则其子网掩码将为 255.255.0.0,路由前缀将为 172.31.0.0。

一般来说,路由前缀的长度必须明确指定。无类域间路由CIDR)表示法使用点分四进制表示法,并在末尾加上一个斜杠和一个介于 0 和 32 之间的数字,表示前缀长度。因此,10.209.72.221/22 表示具有前缀长度为 22 的 IP 地址。一个旧的分类方案,称为有类方案,将 IPv4 地址空间划分为范围,并为每个范围分配一个(见下表)。属于每个范围的地址被认为是相应类的地址,并且路由前缀的长度是基于类确定的,而不是使用 CIDR 表示法指定。

地址范围前缀长度子网掩码备注
类 A0.0.0.0 – 127.255.255.2558255.0.0.0
类 B128.0.0.0 – 191.255.255.25516255.255.0.0
类 C192.0.0.0 – 223.255.255.25524255.255.255.0
类 D224.0.0.0 – 239.255.255.255未指定未指定多播
类 E240.0.0.0 – 255.255.255.255未指定未指定保留
特殊地址

一些 IPv4 地址具有特殊含义。例如,主机部分中所有位设置的 IP 地址被称为子网的广播地址,用于向子网中的所有主机广播消息。例如,网络 172.31.0.0/16 中的广播地址为 172.31.255.255。

监听传入请求的应用程序使用未指定地址 0.0.0.0(INADDR_ANY)来监听所有可用的网络接口,而无需知道系统上的地址。

回环地址 127.0.0.1 通常与一个虚拟网络接口相关联,该接口不与任何硬件相关,并且不需要主机连接到网络。通过回环接口发送的数据立即显示为发送方主机上的接收数据。经常用于在一个盒子内测试网络应用程序,您可以配置额外的回环接口,并将回环地址从 127.0.0.0 到 127.255.255.255 的范围关联起来。

使用 Boost 处理 IPv4 地址

现在让我们看一个构造 IPv4 地址并从中获取有用信息的代码示例,使用类型boost::asio::ip::address_v4

清单 11.6:处理 IPv4 地址

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 #include <vector>
 5 namespace asio = boost::asio;
 6 namespace sys = boost::system;
 7 using namespace asio::ip;
 8
 9 void printAddrProperties(const address& addr) {
10   std::cout << "\n\n" << addr << ": ";
11
12   if (addr.is_v4()) {
13     std::cout << "netmask=" << address_v4::netmask(addr.to_v4());
14   } else if (addr.is_v6()) { /* ... */ }
15
16   if (addr.is_unspecified()) { std::cout << "is unspecified, "; }
17   if (addr.is_loopback()) { std::cout << "is loopback, "; }
18   if (addr.is_multicast()) { std::cout << "is multicast, "; }
19 }
20
21 int main() {
22   sys::error_code ec;
23   std::vector<address> addresses;
24   std::vector<const char*> addr_strings{"127.0.0.1", 
25            "10.28.25.62", "137.2.33.19", "223.21.201.30",
26            "232.28.25.62", "140.28.25.62/22"};
27
28   addresses.push_back(address_v4());       // default: 0.0.0.0
29   addresses.push_back(address_v4::any());  // INADDR_ANY
30
31   for (const auto& v4str : addr_strings) {
32     address_v4 addr = address_v4::from_string(v4str, ec);
33     if (!ec) {
34       addresses.push_back(addr);
35     }
36   }
37
38   for (const address& addr1: addresses) {
39     printAddrProperties(addr1);
40   }
41 }

这个例子突出了 IPv4 地址的一些基本操作。我们创建了一个boost::asio::ip::address对象的向量(不仅仅是address_v4),并从它们的字符串表示中构造 IPv4 地址,使用address_v4::from_string静态函数(第 32 行)。我们使用from_string的两个参数重载,它接受地址字符串和一个非 const 引用到error_code对象,如果无法解析地址字符串,则设置该对象。存在一个单参数重载,如果有错误则抛出。请注意,您可以隐式转换或分配address_v4实例到address实例。默认构造的address_v4实例等同于未指定地址 0.0.0.0(第 28 行),也可以由address_v4::any()(第 29 行)返回。

为了打印地址的属性,我们编写了printAddrProperties函数(第 9 行)。我们通过将 IP 地址流式传输到std::cout(第 10 行)来打印 IP 地址。我们使用is_v4is_v6成员函数(第 12、14 行)来检查地址是 IPv4 还是 IPv6 地址,使用address_v4::netmask静态函数(第 13 行)打印 IPv4 地址的网络掩码,并使用适当的成员谓词(第 16-18 行)检查地址是否为未指定地址、回环地址或 IPv4 多播地址(类 D)。请注意,address_v4::from_string函数不识别 CIDR 格式(截至 Boost 版本 1.57),并且网络掩码是基于类别的方案计算的。

在下一节中,我们将在简要概述 IPv6 地址之后,增强printAddrProperties(第 14 行)函数,以打印 IPv6 特定属性。

IPv6 地址

在其最一般的形式中,IPv6 地址被表示为由冒号分隔的八个 2 字节无符号十六进制整数序列。按照惯例,十六进制整数中的数字af以小写字母写入,并且每个 16 位数字中的前导零被省略。以下是以这种表示法的 IPv6 地址的一个例子:

2001:0c2f:003a:01e0:0000:0000:0000:002a

两个或多个零项的序列可以完全折叠。因此,前面的地址可以写成 2001:c2f:3a:1e0::2a。所有前导零已被移除,并且在字节 16 和 63 之间的连续零项已被折叠,留下了冒号对(:😃。如果有多个零项序列,则折叠最长的序列,如果有平局,则折叠最左边的序列。因此,我们可以将 2001:0000:0000:01e0:0000:0000:001a:002a 缩写为 2001::1e0:0:0:1a:2a。请注意,最左边的两个零项序列被折叠,而 32 到 63 位之间的其他零项未被折叠。

在从 IPv4 过渡到 IPv6 的环境中,软件通常同时支持 IPv4 和 IPv6。IPv4 映射的 IPv6 地址用于在 IPv6 和 IPv4 接口之间进行通信。IPv4 地址被映射到具有::ffff:0:0/96 前缀和最后 32 位与 IPv4 地址相同的 IPv6 地址。例如,172.31.201.43 将表示为::ffff:172.31.201.43/96。

地址类、范围和子网

IPv6 地址有三类:

  • 单播地址:这些地址标识单个网络接口

  • 多播地址:这些地址标识一组网络接口,并用于向组中的所有接口发送数据

  • 任播地址:这些地址标识一组网络接口,但发送到任播地址的数据将传递给距离发送者拓扑最近的一个或多个接口,而不是传递给组中的所有接口

在单播和任播地址中,地址的最低有效 64 位表示主机 ID。一般来说,高阶 64 位表示网络前缀。

每个 IPv6 地址也有一个范围,用于标识其有效的网络段:

  • 节点本地地址,包括环回地址,用于节点内通信。

  • 全局地址是可通过网络到达的可路由地址。

  • 链路本地地址会自动分配给每个启用 IPv6 的接口,并且只能在网络内访问,也就是说,路由器不会路由到链路本地地址的流量。即使具有可路由地址,链路本地地址也会分配给接口。链路本地地址的前缀为 fe80::/64。

特殊地址

IPv6 的环回地址类似于 IPv4 中的 127.0.0.1,为::1。在 IPv6 中,未指定地址(全零)写为::(in6addr_any)。IPv6 中没有广播地址,多播地址用于定义接收方接口的组,这超出了本书的范围。

使用 Boost 处理 IPv6 地址

在下面的例子中,我们构造 IPv6 地址,并使用boost::asio::ip::address_v6类查询这些地址的属性:

列表 11.7:处理 IPv6 地址

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 #include <vector>
 4 namespace asio = boost::asio;
 5 namespace sys = boost::system;
 6 using namespace asio::ip;
 7
 8 void printAddr6Properties(const address_v6& addr) {
 9   if (addr.is_v4_mapped()) { std::cout << "is v4-mapped, "; }
10   else {  
11     if (addr.is_link_local()) { std::cout << "is link local";}
12   }  
13 }
14
15 void printAddrProperties(const address& addr) { ... }
16
17 int main() {
18   sys::error_code ec;
19   std::vector<address> addresses;
20   std::vector<const char*> addr_strings{"::1", "::",
21     "fe80::20", "::ffff:223.18.221.9", "2001::1e0:0:0:1a:2a"};
22
23   for (const auto& v6str: addr_strings) {
24     address addr = address_v6::from_string(v6str, ec);
25     if (!ec) { addresses.push_back(addr); }
26   }
27
28   for (const auto& addr : addresses) {
29     printAddrProperties(addr);
30   }
31 }

这个例子通过 IPv6 特定的检查增强了列表 11.6。函数printAddrProperties(第 15 行)与列表 11.6 中的相同,因此不再完整重复。printAddr6Properties函数(第 8 行)检查地址是否为 IPv4 映射的 IPv6 地址(第 9 行),以及它是否为链路本地地址(第 11 行)。其他相关检查已经通过printAddrProperties中的与版本无关的address成员执行(参见列表 11.6)。

我们创建一个boost::asio::ip::address对象的向量(不仅仅是address_v6),并推送由它们的字符串表示构造的 IPv6 地址,使用address_v6::from_string静态函数(第 24 行),它返回address_v6对象,可以隐式转换为address。请注意,我们有环回地址、未指定地址、IPv4 映射地址、常规 IPv6 单播地址和链路本地地址(第 20-21 行)。

端点、套接字和名称解析

应用程序在提供网络服务时绑定到 IP 地址,多个应用程序从 IP 地址开始发起对其他应用程序的出站通信。多个应用程序可以使用不同的端口绑定到同一个 IP 地址。端口是一个无符号的 16 位整数,与 IP 地址和协议(TCP、UDP 等)一起,唯一标识一个通信端点。数据通信发生在两个这样的端点之间。Boost Asio 为 UDP 和 TCP 提供了不同的端点类型,即boost::asio::ip::udp::endpointboost::asio::ip::tcp::endpoint

端口

许多标准和广泛使用的网络服务使用固定的众所周知的端口。端口 0 到 1023 分配给众所周知的系统服务,包括 FTP、SSH、telnet、SMTP、DNS、HTTP 和 HTTPS 等。广泛使用的应用程序可以在 1024 到 49151 之间注册标准端口,由互联网编号分配机构IANA)负责。49151 以上的端口可以被任何应用程序使用,无需注册。通常将众所周知的端口映射到服务的映射通常保存在磁盘文件中,例如在 POSIX 系统上是/etc/services,在 Windows 上是%SYSTEMROOT%\system32\drivers\etc\services

套接字

套接字表示用于网络通信的端点。它表示通信通道的一端,并提供执行所有数据通信的接口。Boost Asio 为 UDP 和 TCP 提供了不同的套接字类型,即boost::asio::ip::udp::socketboost::asio::ip::tcp::socket。套接字始终与相应的本地端点对象相关联。所有现代操作系统上的本机网络编程接口都使用某种伯克利套接字 API 的衍生版本,这是用于执行网络通信的 C API。Boost Asio 库提供了围绕这个核心 API 构建的类型安全抽象。

套接字是I/O 对象的一个例子。在 Asio 中,I/O 对象是用于启动 I/O 操作的对象类。这些操作由底层操作系统的I/O 服务对象分派,该对象是boost::asio::io_service的实例。在本章的前面,我们看到了 I/O 服务对象作为任务管理器的实例。但是它们的主要作用是作为底层操作系统上操作的接口。每个 I/O 对象都是使用关联的 I/O 服务实例构造的。通过这种方式,高级 I/O 操作在 I/O 对象上启动,但是 I/O 对象和 I/O 服务之间的交互保持封装。在接下来的章节中,我们将看到使用 UDP 和 TCP 套接字进行网络通信的示例。

主机名和域名

通过名称而不是数字地址来识别网络中的主机通常更方便。域名系统(DNS)提供了一个分层命名系统,其中网络中的主机通过带有唯一名称的主机名来标识,该名称标识了网络,称为完全限定域名或简称域名。例如,假想的域名elan.taliesyn.org可以映射到 IP 地址 140.82.168.29。在这里,elan将标识特定主机,taliesyn.org将标识主机所属的域。在同一网络中,不同组的计算机可能报告给不同的域,甚至某台计算机可能属于多个域。

名称解析

全球范围内的 DNS 服务器层次结构以及私人网络内的 DNS 服务器维护名称到地址的映射。应用程序询问配置的 DNS 服务器以解析完全限定域名到地址。如果有的话,DNS 服务器将请求解析为 IP 地址,否则将其转发到层次结构更高的另一个 DNS 服务器。如果直到层次结构的根部都没有答案,解析将失败。发起这种名称解析请求的专门程序或库称为解析器。Boost Asio 提供了特定协议的解析器:boost::asio::ip::tcp::resolverboost::asio::ip::udp::resolver用于执行此类名称解析。我们查询主机名上的服务,并获取该服务的一个或多个端点。以下示例显示了如何做到这一点,给定一个主机名,以及可选的服务名或端口:

清单 11.8:查找主机的 IP 地址

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main(int argc, char *argv[]) {
 6   if (argc < 2) {
 7     std::cout << "Usage: " << argv[0] << " host [service]\n";
 8     exit(1);
 9   }
10   const char *host = argv[1];
11   const char *svc = (argc > 2) ? argv[2] : "";
12
13   try {
14     asio::io_service service;
15     asio::ip::tcp::resolver resolver(service);
16     asio::ip::tcp::resolver::query query(host, svc);
17     asio::ip::tcp::resolver::iterator end,
18                             iter = resolver.resolve(query);
19     while (iter != end) {
20       asio::ip::tcp::endpoint endpoint = iter->endpoint();
21       std::cout << "Address: " << endpoint.address()
22                 << ", Port: " << endpoint.port() << '\n';
23       ++iter;
24     }
25   } catch (std::exception& e) {
26     std::cout << e.what() << '\n';
27   }
28 }

您可以通过在命令行上传递主机名和可选的服务名来运行此程序。该程序将这些解析为 IP 地址和端口,并将它们打印到标准输出(第 21-22 行)。程序创建了一个io_service实例(第 14 行),它将成为底层操作系统操作的通道,以及一个boost::asio::ip::tcp::resolver实例(第 15 行),它提供了请求名称解析的接口。我们根据主机名和服务名创建一个名称查找请求,封装在一个query对象中(第 16 行),并调用resolverresolve成员函数,将query对象作为参数传递(第 18 行)。resolve函数返回一个endpoint iterator,指向查询解析的一系列endpoint对象。我们遍历这个序列,打印每个端点的地址和端口号。如果有的话,这将打印 IPv4 和 IPv6 地址。如果我们想要特定于 IP 版本的 IP 地址,我们需要使用query的三参数构造函数,并在第一个参数中指定协议。例如,要仅查找 IPv6 地址,我们可以使用这个:

asio::ip::tcp::resolver::query query(asio::ip::tcp::v6(), 
 host, svc);

在查找失败时,resolve函数会抛出异常,除非我们使用接受非 const 引用error_code的两参数版本,并在错误时设置它。在下面的例子中,我们执行反向查找。给定一个 IP 地址和一个端口,我们查找关联的主机名和服务名:

清单 11.9:查找主机和服务名称

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main(int argc, char *argv[]) {
 6   if (argc < 2) {
 7     std::cout << "Usage: " << argv[0] << " ip [port]\n";
 8     exit(1);
 9   }
10
11   const char *addr = argv[1];
12   unsigned short port = (argc > 2) ? atoi(argv[2]) : 0;
13
14   try {
15     asio::io_service service;
16     asio::ip::tcp::endpoint ep(
17               asio::ip::address::from_string(addr), port);
18     asio::ip::tcp::resolver resolver(service);
19     asio::ip::tcp::resolver::iterator iter = 
20                              resolver.resolve(ep), end;
21     while (iter != end) {
22       std::cout << iter->host_name() << " "
23                 << iter->service_name() << '\n';
24       iter++;
25     }
26   } catch (std::exception& ex) {
27     std::cerr << ex.what() << '\n';
28   }
29 }

我们从命令行传递 IP 地址和端口号给程序,然后使用它们构造endpoint(第 16-17 行)。然后我们将endpoint传递给resolverresolve成员函数(第 19 行),并遍历结果。在这种情况下,迭代器指向boost::asio::ip::tcp::query对象,我们使用适当的成员函数打印每个对象的主机和服务名称(第 22-23 行)。

缓冲区

数据作为字节流通过网络发送或接收。一个连续的字节流可以用一对值来表示:序列的起始地址和序列中的字节数。Boost Asio 提供了两种用于这种序列的抽象,boost::asio::const_bufferboost::asio::mutable_bufferconst_buffer类型表示一个只读序列,通常用作发送数据时的数据源。mutable_buffer表示一个读写序列,当您需要在缓冲区中添加或更新数据时使用,例如当您从远程主机接收数据时:

清单 11.10:使用 const_buffer 和 mutable_buffer

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 #include <cassert>
 4 namespace asio = boost::asio;
 5
 6 int main() {
 7   char buf[10];
 8   asio::mutable_buffer mbuf(buf, sizeof(buf));
 9   asio::const_buffer cbuf(buf, 5);
10
11   std::cout << buffer_size(mbuf) << '\n';
12   std::cout << buffer_size(cbuf) << '\n';
13
14   char *mptr = asio::buffer_cast<char*>(mbuf);
15   const char *cptr = asio::buffer_cast<const char*>(cbuf);
16   assert(mptr == cptr && cptr == buf);
17   
18   size_t offset = 5;
19   asio::mutable_buffer mbuf2 = mbuf + offset;
20   assert(asio::buffer_cast<char*>(mbuf2)
21         - asio::buffer_cast<char*>(mbuf) == offset);
22   assert(buffer_size(mbuf2) == buffer_size(mbuf) - offset);
23 }

在这个例子中,我们展示了如何将 char 数组包装在mutable_bufferconst_buffer中(第 8-9 行)。在构造缓冲区时,您需要指定内存区域的起始地址和区域的字节数。const char数组只能被包装在const_buffer中,而不能被包装在mutable_buffer中。这些缓冲区包装器分配存储空间,不管理任何堆分配的内存,也不执行任何数据复制。

函数boost::asio::buffer_size返回缓冲区的字节长度(第 11-12 行)。这是您在构造缓冲区时传递的长度,它不依赖于缓冲区中的数据。默认初始化的缓冲区长度为零。

函数模板boost::asio::buffer_cast<>用于获取缓冲区的基础字节数组的指针(第 14-15 行)。请注意,如果我们尝试使用buffer_castconst_buffer获取可变数组,将会得到编译错误:

asio::const_buffer cbuf(addr, length);
char *buf = asio::buffer_cast<char*>(cbuf); // fails to compile

最后,您可以使用operator+从另一个缓冲区的偏移量创建一个缓冲区(第 19 行)。结果缓冲区的长度将比原始缓冲区的长度少偏移量的长度(第 22 行)。

向量 I/O 的缓冲区序列

有时,从一系列缓冲区发送数据或将接收到的数据分割到一系列缓冲区中是很方便的。每个序列调用一次网络 I/O 函数会很低效,因为这些调用最终会转换为系统调用,并且每次调用都会有开销。另一种选择是使用可以处理作为参数传递给它的缓冲区序列的网络 I/O 函数。这通常被称为向量 I/O聚集-分散 I/O。Boost Asio 的所有 I/O 函数都处理缓冲区序列,因此必须传递缓冲区序列而不是单个缓冲区。用于 Asio I/O 函数的有效缓冲区序列满足以下条件:

  • 有一个返回双向迭代器的成员函数begin,该迭代器指向mutable_bufferconst_buffer

  • 有一个返回指向序列末尾的迭代器的成员函数end

  • 可复制

要使缓冲区序列有用,它必须是const_buffer序列或mutable_buffer序列。形式上,这些要求总结在ConstBufferSequenceMutableBufferSequence概念中。这是一组稍微简化的条件,但对我们的目的来说已经足够了。我们可以使用标准库容器(如std::vectorstd::list等)以及 Boost 容器来创建这样的序列。然而,由于我们经常只处理单个缓冲区,Boost 提供了boost::asio::buffer函数,可以轻松地将单个缓冲区适配为长度为 1 的缓冲区序列。以下是一个简短的示例,说明了这些想法:

清单 11.11:使用缓冲区

 1 #include <boost/asio.hpp>
 2 #include <vector>
 3 #include <string>
 4 #include <iostream>
 5 #include <cstdlib>
 6 #include <ctime>
 7 namespace asio = boost::asio;
 8
 9 int main() {
10   std::srand(std::time(nullptr));
11
12   std::vector<char> v1(10);
13   char a2[10];
14   std::vector<asio::mutable_buffer> bufseq(2);
15
16   bufseq.push_back(asio::mutable_buffer(v1.data(), 
17                                         v1.capacity()));
18   bufseq.push_back(asio::mutable_buffer(a2, sizeof(a2)));
19
20   for (auto cur = asio::buffers_begin(bufseq),
21        end = asio::buffers_end(bufseq); cur != end; cur++) {
22     *cur = 'a' + rand() % 26;
23   }
24
25   std::cout << "Size: " << asio::buffer_size(bufseq) << '\n';
26
27   std::string s1(v1.begin(), v1.end());
28   std::string s2(a2, a2 + sizeof(a2));
29
30   std::cout << s1 << '\n' << s2 << '\n';
31 }

在这个示例中,我们创建一个vector的两个mutable_buffer的可变缓冲区序列(第 14 行)。这两个可变缓冲区包装了一个charvector(第 16-17 行)和一个char的数组(第 18 行)。使用buffers_begin函数(第 20 行)和buffers_end函数(第 21 行),我们确定了缓冲区序列bufseq所封装的字节的整个范围,并遍历它,将每个字节设置为随机字符(第 22 行)。当这些写入底层的 vector 或数组时,我们使用底层的 vector 或数组构造字符串并打印它们的内容(第 27-28 行)。

同步和异步通信

在接下来的几节中,我们将整合我们迄今为止学到的 IP 地址、端点、套接字、缓冲区和其他 Asio 基础设施的理解,来编写网络客户端和服务器程序。我们的示例使用客户端-服务器模型进行交互,其中服务器程序服务于传入的请求,而客户端程序发起这些请求。这样的客户端被称为主动端点,而这样的服务器被称为被动端点

客户端和服务器可以进行同步通信,即在每个网络 I/O 操作上阻塞,直到请求被底层操作系统处理,然后才继续下一步。或者,它们可以使用异步 I/O,在不等待完成的情况下启动网络 I/O,并在稍后被通知其完成。与同步情况不同,使用异步 I/O 时,程序不会在需要执行 I/O 操作时空闲等待。因此,异步 I/O 在具有更多对等方和更大数据量时具有更好的扩展性。我们将研究通信的同步和异步模型。虽然异步交互的编程模型是事件驱动的且更复杂,但使用 Boost Asio 协程可以使其非常易于管理。在编写 UDP 和 TCP 服务器之前,我们将看一下 Asio 截止时间定时器,以了解如何使用 Asio 编写同步和异步逻辑。

Asio 截止时间定时器

Asio 提供了basic_deadline_timer模板,使用它可以等待特定持续时间的过去或绝对时间点。特化的deadline_timer定义如下:

typedef basic_deadline_timer<boost::posix_time::ptime> 
                                             deadline_timer;

它使用boost::posix_time::ptimeboost::posix_time::time_duration作为时间点和持续时间类型。下面的例子演示了一个应用程序如何使用deadline_timer等待一段时间:

清单 11.12:同步等待

 1 #include <boost/asio.hpp>
 2 #include <boost/date_time.hpp>
 3 #include <iostream>
 4
 5 int main() {
 6   boost::asio::io_service service;
 7   boost::asio::deadline_timer timer(service);
 8
 9   long secs = 5;
10   std::cout << "Waiting for " << secs << " seconds ..." 
11             << std::flush;
12   timer.expires_from_now(boost::posix_time::seconds(secs));
13
14   timer.wait();
15 
16   std::cout << " done\n";
17 }

我们创建了一个io_service对象(第 6 行),它作为底层操作的通道。我们创建了一个与io_service相关联的deadline_timer实例(第 7 行)。我们使用deadline_timerexpires_from_now成员函数指定了一个 5 秒的等待时间(第 12 行)。然后我们调用wait成员函数来阻塞直到时间到期。注意我们不需要在io_service实例上调用run。我们可以使用expires_at成员函数来等待到特定的时间点,就像这样:

using namespace boost::gregorian;
using namespace boost::posix_time;

timer.expires_at(day_clock::local_day(), 
                 hours(16) + minutes(12) + seconds(58));

有时,程序不想阻塞等待定时器触发,或者一般来说,不想阻塞等待它感兴趣的任何未来事件。与此同时,它可以完成其他有价值的工作,因此比起阻塞等待事件,它可以更加响应。我们不想在事件上阻塞,只是想告诉定时器在触发时通知我们,并且同时进行其他工作。为此,我们调用async_wait成员函数,并传递一个完成处理程序。完成处理程序是我们使用async_wait注册的函数对象,一旦定时器到期就会被调用:

清单 11.13:异步等待

 1 #include <boost/asio.hpp>
 2 #include <boost/date_time.hpp>
 3 #include <iostream>
 4
 5 void on_timer_expiry(const boost::system::error_code& ec)
 6 {
 7   if (ec) {
 8     std::cout << "Error occurred while waiting\n";
 9   } else {
10     std::cout << "Timer expired\n";
11   }
12 }
13
14 int main()
15 {
16   boost::asio::io_service service;
17   boost::asio::deadline_timer timer(service);
18
19
20   long secs = 5;
21   timer.expires_from_now(boost::posix_time::seconds(secs));
22
23   std::cout << "Before calling deadline_timer::async_wait\n";
24   timer.async_wait(on_timer_expiry);
25   std::cout << "After calling deadline_timer::async_wait\n";
26
27   service.run();
28 }

与清单 11.12 相比,清单 11.13 有两个关键的变化。我们调用deadline_timerasync_wait成员函数而不是wait,并传递一个指向完成处理程序函数on_timer_expiry的指针。然后在io_service对象上调用run。当我们运行这个程序时,它会打印以下内容:

Before calling deadline_timer::async_wait
After calling deadline_timer::async_wait
Timer expired

调用async_wait不会阻塞(第 24 行),因此前两行消息会快速连续打印出来。随后,调用run(第 27 行)会阻塞直到定时器到期,并且定时器的完成处理程序被调度。除非发生了错误,否则完成处理程序会打印Timer expired。因此,第一和第二条消息出现与第三条消息之间存在时间差,第三条消息是完成处理程序的输出。

使用 Asio 协程的异步逻辑

deadline_timerasync_wait成员函数启动了一个异步操作。这样的函数在启动的操作完成之前就返回了。它注册了一个完成处理程序,并且通过调用这个处理程序来通知程序异步事件的完成。如果我们需要按顺序运行这样的异步操作,控制流就会变得复杂。例如,假设我们想等待 5 秒,打印Hello,然后再等待 10 秒,最后打印world。使用同步的wait,就像下面的代码片段一样简单:

boost::asio::deadline_timer timer;
timer.expires_from_now(boost::posix_time::seconds(5));
timer.wait();
std::cout << "Hello, ";
timer.expires_from_now(boost::posix_time::seconds(10));
timer.wait();
std::cout << "world!\n";

在许多现实场景中,特别是在网络 I/O 中,阻塞同步操作根本不是一个选择。在这种情况下,代码变得更加复杂。使用async_wait作为模型异步操作,下面的例子演示了异步代码的复杂性:

清单 11.14:异步操作

 1 #include <boost/asio.hpp>
 2 #include <boost/bind.hpp>
 3 #include <boost/date_time.hpp>
 4 #include <iostream>
 5
 6 void print_world(const boost::system::error_code& ec) {
 7   std::cout << "world!\n";
 8 }
 9
10 void print_hello(boost::asio::deadline_timer& timer,
11                  const boost::system::error_code& ec) {
12   std::cout << "Hello, " << std::flush;
13
14   timer.expires_from_now(boost::posix_time::seconds(10));
15   timer.async_wait(print_world);
16 }
17
18 int main()
19 {
20   boost::asio::io_service service;
21   boost::asio::deadline_timer timer(service);
22   timer.expires_from_now(boost::posix_time::seconds(5));
23
24   timer.async_wait(boost::bind(print_hello, boost::ref(timer),
25                                            ::_1));
26
27   service.run();
28 }

将相同功能从同步逻辑转换为异步逻辑,代码行数超过两倍,控制流也变得复杂。我们将函数print_hello(第 10 行)注册为第一个 5 秒等待的完成处理程序(第 22、24 行)。print_hello又使用同一个定时器开始了一个 10 秒的等待,并将函数print_world(第 6 行)注册为这个等待的完成处理程序(第 14-15 行)。

请注意,我们使用boost::bind为第一个 5 秒等待生成完成处理程序,将timermain函数传递给print_hello函数。因此,print_hello函数使用相同的计时器。为什么我们需要这样做呢?首先,print_hello需要使用相同的io_service实例来启动 10 秒等待操作和之前的 5 秒等待。timer实例引用了这个io_service实例,并且被两个完成处理程序使用。此外,在print_hello中创建一个本地的deadline_timer实例会有问题,因为print_hello会在计时器响起之前返回,并且本地计时器对象会被销毁,所以它永远不会响起。

示例 11.14 说明了控制流反转的问题,在异步编程模型中是一个重要的复杂性来源。我们不能再将一系列语句串在一起,并假设每个语句只有在前面的操作完成后才会启动一个操作——这对于同步模型是一个安全的假设。相反,我们依赖于io_service的通知来确定运行下一个操作的正确时间。逻辑在函数之间分散,需要更多的努力来管理需要在这些函数之间共享的任何数据。

Asio 使用 Boost Coroutine 库的薄包装简化了异步编程。与 Boost Coroutine 一样,可以使用有栈和无栈协程。在本书中,我们只研究有栈协程。

使用boost::asio::spawn函数模板,我们可以启动任务作为协程。如果一个协程被调度并调用了一个异步函数,那么协程会被暂停。与此同时,io_service会调度其他任务,包括其他协程。一旦异步操作完成,启动它的协程会恢复,并继续下一步。在下面的清单中,我们使用协程重写清单 11.14:

清单 11.15:使用协程进行异步编程

 1 #include <boost/asio.hpp>
 2 #include <boost/asio/spawn.hpp>
 3 #include <boost/bind.hpp>
 4 #include <boost/date_time.hpp>
 5 #include <iostream>
 6
 7 void wait_and_print(boost::asio::yield_context yield,
 8                     boost::asio::io_service& service)
 9 {
10   boost::asio::deadline_timer timer(service);
11
12   timer.expires_from_now(boost::posix_time::seconds(5));
13   timer.async_wait(yield);
14   std::cout << "Hello, " << std::flush;
15 
16   timer.expires_from_now(boost::posix_time::seconds(10));
17   timer.async_wait(yield);
18   std::cout << "world!\n";
19 }
20
21 int main()
22 {
23   boost::asio::io_service service;
24   boost::asio::spawn(service,
25           boost::bind(wait_and_print, ::_1, 
26                                       boost::ref(service)));
27   service.run();
28 }

wait_and_print函数是协程,接受两个参数:一个boost::asio::yield_context类型的对象和一个io_service实例的引用(第 7 行)。yield_context是 Boost Coroutine 的薄包装。我们必须使用boost::asio::spawn来调度一个协程,这样一个协程必须具有void (boost::asio::yield_context)的签名。因此,我们使用boost::bind来使wait_and_print函数与spawn期望的协程签名兼容。我们将第二个参数绑定到io_service实例的引用(第 24-26 行)。

wait_and_print协程在堆栈上创建一个deadline_timer实例,并开始一个 5 秒的异步等待,将其yield_context传递给async_wait函数,而不是完成处理程序。这会暂停wait_and_print协程,只有在等待完成后才会恢复。与此同时,如果有其他任务,可以从io_service队列中处理。等待结束并且wait_and_print恢复后,它打印Hello并开始等待 10 秒。协程再次暂停,只有在 10 秒后才会恢复,然后打印world。协程使异步逻辑与同步逻辑一样简单易读,开销很小。在接下来的章节中,我们将使用协程来编写 TCP 和 UDP 服务器。

UDP

UDP I/O 模型相对简单,客户端和服务器之间的区别模糊不清。对于使用 UDP 的网络 I/O,我们创建一个 UDP 套接字,并使用send_toreceive_from函数将数据报发送到特定的端点。

同步 UDP 客户端和服务器

在本节中,我们编写了一个 UDP 客户端(清单 11.16)和一个同步 UDP 服务器(清单 11.17)。UDP 客户端尝试向给定端点的 UDP 服务器发送一些数据。UDP 服务器阻塞等待从一个或多个 UDP 客户端接收数据。发送数据后,UDP 客户端阻塞等待从服务器接收响应。服务器在接收数据后,在继续处理更多传入消息之前发送一些响应。

清单 11.16:同步 UDP 客户端

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 #include <exception>
 4 namespace asio = boost::asio;
 5
 6 int main(int argc, char *argv[]) {
 7   if (argc < 3) {
 8     std::cerr << "Usage: " << argv[0] << " host port\n";
 9     return 1;
10   }
11
12   asio::io_service service;
13   try {
14     asio::ip::udp::resolver::query query(asio::ip::udp::v4(),
15                                        argv[1], argv[2]);
16     asio::ip::udp::resolver resolver(service);
17     auto iter = resolver.resolve(query);
18     asio::ip::udp::endpoint endpoint = iter->endpoint();
19   
20     asio::ip::udp::socket socket(service, 
21                                  asio::ip::udp::v4());
22     const char *msg = "Hello from client";
23     socket.send_to(asio::buffer(msg, strlen(msg)), endpoint);
24     char buffer[256];
25     size_t recvd = socket.receive_from(asio::buffer(buffer,
26                                  sizeof(buffer)), endpoint);
27     buffer[recvd] = 0;
28     std::cout << "Received " << buffer << " from " 
29        << endpoint.address() << ':' << endpoint.port() << '\n';
30   } catch (std::exception& e) {
31     std::cerr << e.what() << '\n';
32   }
33 }

我们通过命令行向客户端传递服务器主机名和要连接的服务(或端口)。它们会解析为 UDP 的端点(IP 地址和端口号)(第 13-17 行),为 IPv4 创建一个 UDP 套接字(第 18 行),并在其上调用send_to成员函数。我们传递给send_to一个包含要发送的数据和目标端点的const_buffer(第 23 行)。

每个使用 Asio 执行网络 I/O 的程序都使用I/O 服务,它是类型boost::asio::io_service的实例。我们已经看到io_service作为任务管理器的作用。但是 I/O 服务的主要作用是作为底层操作的接口。Asio 程序使用负责启动 I/O 操作的I/O 对象。例如,套接字就是 I/O 对象。

我们调用 UDP 套接字的send_to成员函数,向服务器发送预定义的消息字符串(第 23 行)。请注意,我们将消息数组包装在长度为 1 的缓冲区序列中,该序列使用boost::asio::buffer函数构造,如本章前面所示,在缓冲区部分。一旦send_to完成,客户端在同一套接字上调用recv_from,传递一个可变的缓冲区序列,该序列由可写字符数组使用boost::asio::buffer构造(第 25-26 行)。receive_from的第二个参数是对boost::asio::ip::udp::endpoint对象的非 const 引用。当receive_from返回时,该对象包含发送消息的远程端点的地址和端口号(第 28-29 行)。

调用send_toreceive_from的是阻塞调用。调用send_to不会返回,直到传递给它的缓冲区已经被写入系统中的底层 UDP 缓冲区。将 UDP 缓冲区通过网络发送到服务器可能会在稍后发生。调用receive_from不会返回,直到接收到一些数据为止。

我们可以使用单个 UDP 套接字向多个其他端点发送数据,并且可以在单个套接字上从多个其他端点接收数据。因此,每次调用send_to都将目标端点作为输入。同样,每次调用receive_from都会使用非 const 引用传递一个端点,并在返回时将其设置为发送方的端点。现在我们将使用 Asio 编写相应的 UDP 服务器:

清单 11.17:同步 UDP 服务器

 1 #include <boost/asio.hpp>
 2 #include <exception>
 4 #include <iostream>
 5 namespace asio = boost::asio;
 6
 8 int main() 
 9 {
10   const unsigned short port = 55000;
11   const std::string greet("Hello, world!");
12
13   asio::io_service service;
14   asio::ip::udp::endpoint endpoint(asio::ip::udp::v4(), port);
15   asio::ip::udp::socket socket(service, endpoint);
16   asio::ip::udp::endpoint ep;
17
18   while (true) try {	
19     char msg[256];
20     auto recvd = socket.receive_from(asio::buffer(msg, 
21                                             sizeof(msg)), ep);
22     msg[recvd] = 0;
23     std::cout << "Received: [" << msg << "] from [" 
24               << ep << "]\n";
25
26     socket.send_to(asio::buffer(greet.c_str(), greet.size()),
27                    ep);
27     socket.send_to(asio::buffer(msg, strlen(msg)), ep);
28   } catch (std::exception& e) {
29     std::cout << e.what() << '\n';
30   }
31 }

同步 UDP 服务器在端口 55000 上创建一个boost::asio::ip::udp::endpoint类型的单个 UDP 端点,保持地址未指定(第 14 行)。请注意,我们使用了一个两参数的endpoint构造函数,它将协议和端口作为参数。服务器为此端点创建一个boost::asio::ip::udp::socket类型的单个 UDP 套接字(第 15 行),并在循环中旋转,每次迭代调用套接字上的receive_from,等待直到客户端发送一些数据。数据以一个名为msgchar数组接收,该数组被包装在长度为一的可变缓冲序列中传递给receive_fromreceive_from的调用返回接收到的字节数,用于在msg中添加一个终止空字符,以便它可以像 C 风格的字符串一样使用(第 22 行)。一般来说,UDP 将传入的数据呈现为包含一系列字节的消息,其解释留给应用程序。每当服务器从客户端接收数据时,它会将发送的数据回显,先前由一个固定的问候字符串。它通过在套接字上两次调用send_to成员函数来实现,传递要发送的缓冲区和接收方的端点(第 26-27 行,28 行)。

send_toreceive_from的调用是同步的,只有当数据完全传递给操作系统(send_to)或应用程序完全接收到数据(receive_from)时才会返回。如果许多客户端实例同时向服务器发送消息,服务器仍然只能一次处理一条消息,因此客户端排队等待响应。当然,如果客户端不等待响应,它们可以全部发送消息并退出,但消息仍然会按顺序被服务器接收。

异步 UDP 服务器

UDP 服务器的异步版本可以显著提高服务器的响应性。传统的异步模型可能涉及更复杂的编程模型,但协程可以显著改善情况。

使用完成处理程序链的异步 UDP 服务器

对于异步通信,我们使用socketasync_receive_fromasync_send_to成员函数。这些函数不会等待 I/O 请求被操作系统处理,而是立即返回。它们被传递一个函数对象,当底层操作完成时将被调用。这个函数对象被排队在io_service的任务队列中,在操作系统上的实际操作返回时被调度。

template <typename MutableBufSeq, typename ReadHandler>
deduced async_receive_from(
    const MutableBufSeq& buffers,
    endpoint_type& sender_ep,
 ReadHandler handler);

template <typename ConstBufSeq, typename WriteHandler>
deduced async_send_to(
    const ConstBufSeq& buffers,
    endpoint_type& sender_ep,
 WriteHandler handler);

传递给async_receive_from的读处理程序和传递给async_send_to的写处理程序的签名如下:

void(const boost::system::error_code&, size_t)

处理程序期望传递一个非 const 引用给error_code对象,指示已完成操作的状态和读取或写入的字节数。处理程序可以调用其他异步 I/O 操作并注册其他处理程序。因此,整个 I/O 操作是以一系列处理程序的链条来定义的。现在我们来看一个异步 UDP 服务器的程序:

清单 11.18:异步 UDP 服务器

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4 namespace sys = boost::system;
 5
 6 const size_t MAXBUF = 256;
 7
 8 class UDPAsyncServer {
 9 public:
10   UDPAsyncServer(asio::io_service& service, 
11                  unsigned short port) 
12      : socket(service, 
13           asio::ip::udp::endpoint(asio::ip::udp::v4(), port))
14   {  waitForReceive();  }
15
16   void waitForReceive() {
17     socket.async_receive_from(asio::buffer(buffer, MAXBUF),
18           remote_peer,
19           [this] (const sys::error_code& ec,
20                   size_t sz) {
21             const char *msg = "hello from server";
22             std::cout << "Received: [" << buffer << "] "
23                       << remote_peer << '\n';
24             waitForReceive();
25
26             socket.async_send_to(
27                 asio::buffer(msg, strlen(msg)),
28                 remote_peer,
29                 this {});
31           });
32   }
33
34 private:
35   asio::ip::udp::socket socket;
36   asio::ip::udp::endpoint remote_peer;
37   char buffer[MAXBUF];
38 };
39
40 int main() {
41   asio::io_service service;
42   UDPAsyncServer server(service, 55000);
43   service.run();
44 }

UDP 服务器封装在UDPAsyncServer类中(第 8 行)。要启动服务器,我们首先创建必需的io_service对象(第 42 行),然后创建一个UDPAsyncServer实例(第 43 行),该实例传递了io_service实例和应该使用的端口号。最后,调用io_servicerun成员函数开始处理传入的请求(第 44 行)。那么UDPAsyncServer是如何工作的呢?

UDPAsyncServer的构造函数使用本地端点初始化了 UDP socket成员(第 12-13 行)。然后调用成员函数waitForReceive(第 14 行),该函数又在套接字上调用async_receive_from(第 18 行),开始等待任何传入的消息。我们调用async_receive_from,传递了从buffer成员变量制作的可变缓冲区(第 17 行),对remote_peer成员变量的非 const 引用(第 18 行),以及一个定义接收操作完成处理程序的 lambda 表达式(第 19-31 行)。async_receive_from启动了一个 I/O 操作,将处理程序添加到io_service的任务队列中,然后返回。对io_servicerun调用(第 43 行)会阻塞,直到队列中有 I/O 任务。当 UDP 消息到来时,数据被操作系统接收,并调用处理程序来采取进一步的操作。要理解 UDP 服务器如何无限处理更多消息,我们需要了解处理程序的作用。

接收处理程序在服务器接收到消息时被调用。它打印接收到的消息和远程发送者的详细信息(第 22-23 行),然后发出对waitForReceive的调用,从而重新启动接收操作。然后它发送一条消息hello from server(第 21 行)回到由remote_peer成员变量标识的发送者。它通过调用 UDP socketasync_send_to成员函数来实现这一点,传递消息缓冲区(第 27 行),目标端点(第 28 行),以及另一个以 lambda 形式的处理程序(第 29-32 行),该处理程序什么也不做。

请注意,我们在 lambda 中捕获了this指针,以便能够从周围范围访问成员变量(第 20 行,29 行)。另外,处理程序都没有使用error_code参数进行错误检查,这在现实世界的软件中是必须的。

使用协程的异步 UDP 服务器

处理程序链接将逻辑分散到一组处理程序中,并且在处理程序之间共享状态变得特别复杂。这是为了更好的性能,但我们可以避免这个代价,就像我们在列表 11.15 中使用 Asio 协程处理boost::asio::deadline_timer上的异步等待一样。现在我们将使用 Asio 协程来编写一个异步 UDP 服务器:

列表 11.19:使用 Asio 协程的异步 UDP 服务器

 1 #include <boost/asio.hpp>
 2 #include <boost/asio/spawn.hpp>
 3 #include <boost/bind.hpp>
 4 #include <boost/shared_ptr.hpp>
 5 #include <boost/make_shared.hpp>
 6 #include <iostream>
 7 namespace asio = boost::asio;
 8 namespace sys = boost::system;
 9
10 const size_t MAXBUF = 256;
11 typedef boost::shared_ptr<asio::ip::udp::socket>
12                                   shared_udp_socket;
13
14 void udp_send_to(boost::asio::yield_context yield,
15                  shared_udp_socket socket,
16                  asio::ip::udp::endpoint peer)
17 {
18     const char *msg = "hello from server";
19     socket->async_send_to(asio::buffer(msg, std::strlen(msg)),
20                          peer, yield);
21 }
22
23 void udp_server(boost::asio::yield_context yield,
24                 asio::io_service& service,
25                 unsigned short port)
26 {
27   shared_udp_socket socket =
28       boost::make_shared<asio::ip::udp::socket>(service,
29           asio::ip::udp::endpoint(asio::ip::udp::v4(), port));
30
31   char buffer[MAXBUF];
32   asio::ip::udp::endpoint remote_peer;
33   boost::system::error_code ec;
34
35   while (true) {
36     socket->async_receive_from(asio::buffer(buffer, MAXBUF),
37                 remote_peer, yield[ec]);
38
39     if (!ec) {
40       spawn(socket->get_io_service(), 
41         boost::bind(udp_send_to, ::_1, socket,
42                                  remote_peer));
43     }
44   }
45 }
46
47 int main() {
48   asio::io_service service;
49   spawn(service, boost::bind(udp_server, ::_1,
50                      boost::ref(service), 55000));
51   service.run();                               
52 }

通过使用协程,异步 UDP 服务器的结构与列表 11.18 有了相当大的变化,并且更接近列表 11.17 的同步模型。函数udp_server包含了 UDP 服务器的核心逻辑(第 23 行)。它被设计为协程使用,因此它的一个参数是boost::asio::yield_context类型(第 23 行)。它还接受两个额外的参数:对io_service实例的引用(第 24 行)和 UDP 服务器端口(第 25 行)。

在主函数中,我们创建了一个io_service实例(第 48 行),然后添加一个任务以将udp_server作为协程运行,使用boost::asio::spawn函数模板(第 49-50 行)。我们适当地绑定了udp_server的服务和端口参数。然后我们调用io_service实例上的run来开始处理 I/O 操作。对run的调用会派发udp_server协程(第 51 行)。

udp_server 协程创建一个与未指定的 IPv4 地址(0.0.0.0)和作为参数传递的特定端口相关联的 UDP 套接字(第 27-29 行)。 套接字被包装在 shared_ptr 中,稍后将清楚其原因。 协程堆栈上有额外的变量来保存从客户端接收的数据(第 31 行)并标识客户端端点(第 32 行)。 udp_server 函数然后在循环中旋转,调用套接字上的 async_receive_from,传递接收处理程序的 yield_context(第 36-37 行)。 这会暂停 udp_server 协程的执行,直到 async_receive_from 完成。 与此同时,对 run 的调用会恢复并处理其他任务(如果有)。 一旦调用 async_receive_from 函数完成,udp_server 协程将恢复执行并继续进行其循环的下一次迭代。

对于每个完成的接收操作,udp_server 都会发送一个固定的问候字符串(“来自服务器的问候”)作为对客户端的响应。 发送这个问候的任务也封装在一个协程中,即 udp_send_to(第 14 行),udp_server 协程使用 spawn(第 40 行)将其添加到任务队列中。 我们将 UDP 套接字和标识客户端的端点作为参数传递给这个协程。 请注意,称为 remote_peer 的局部变量被按值传递给 udp_send_to 协程(第 42 行)。 这在 udp_send_to 中被使用,作为 async_send_to 的参数,用于指定响应的接收者(第 19-20 行)。 我们传递副本而不是引用给 remote_peer,因为当发出对 async_send_to 的调用时,另一个对 async_receive_from 的调用可能是活动的,并且可能在 async_send_to 使用之前覆盖 remote_peer 对象。 我们还传递了包装在 shared_ptr 中的套接字。 套接字不可复制,不像端点。 如果套接字对象在 udp_server 函数中的自动存储中,并且在仍有待处理的 udp_send_to 任务时 udp_server 退出,那么 udp_send_to 中的套接字引用将无效,并可能导致崩溃。 出于这个原因,shared_ptr 包装器是正确的选择。

如果您注意到,对 async_receive_from 的处理程序写为 yield[ec](第 37 行)。 yield_context 类具有重载的下标运算符,我们可以使用它来指定对 error_code 类型的变量的可变引用。 当异步操作完成时,作为下标运算符参数传递的变量将设置为错误代码(如果有)。

提示

在编写异步服务器时,更倾向于使用协程而不是处理程序链。 协程使代码更简单,控制流更直观。

性能和并发

我们声称异步通信模式提高了服务器的响应性。 让我们确切地了解哪些因素导致了这种改进。 在列表 11.17 的同步模型中,除非 send_to 函数返回,否则无法发出对 receive_from 的调用。 在列表 11.18 的异步代码中,一旦接收并消耗了消息,就会立即调用 waitForReceive(第 23-25 行),它不会等待 async_send_to 完成。 同样,在列表 11.19 中,它展示了在异步模型中使用协程,协程帮助暂停等待异步 I/O 操作完成的函数,并同时继续处理队列中的其他任务。 这是异步服务器响应性改进的主要来源。

值得注意的是,在列表 11.18 中,所有 I/O 都在单个线程上进行。这意味着在任何给定时间点,我们的程序只处理一个传入的 UDP 消息。这使我们能够重用bufferremote_peer成员变量,而不必担心同步。我们仍然必须确保在再次调用waitForReceive之前打印接收到的缓冲区(第 22-23 行)。如果我们颠倒了顺序,缓冲区可能会在打印之前被新的传入消息覆盖。

考虑一下,如果我们像这样在接收处理程序中调用waitForReceive而不是发送处理程序中:

18     socket.async_receive_from(asio::buffer(buffer, MAXBUF),
19           remote_peer,
20           [this] (const sys::error_code& ec,
21                   size_t sz) {
...            ...
26             socket.async_send_to(
27                 asio::buffer(msg, strlen(msg)),
28                 remote_peer,
29                 this {
31                   waitForReceive();
32                 });
33           });

在这种情况下,接收将仅在发送完成后开始;因此,即使使用异步调用,它也不会比列表 11.17 中的同步示例更好。

在列表 11.18 中,我们在发送内容回来时不需要来自远程对等方的缓冲区,因此我们不需要在发送完成之前保留该缓冲区。这使我们能够在不等待发送完成的情况下开始异步接收(第 24 行)。接收可能会首先完成并覆盖缓冲区,但只要发送操作不使用缓冲区,一切都没问题。在现实世界中,这种情况经常发生,因此让我们看看如何在不延迟接收直到发送之后的情况下解决这个问题。以下是处理程序的修改实现:

  17 void waitForReceive() {
 18   boost::shared_array<char> recvbuf(new char[MAXBUF]);
 19   auto epPtr(boost::make_shared<asio::ip::udp::endpoint>());
 20   socket.async_receive_from(
 21         asio::buffer(recvbuf.get(), MAXBUF),
  22         *epPtr,
 23         [this, recvbuf, epPtr] (const sys::error_code& ec,
  24                 size_t sz) {
 25           waitForReceive();
  26
  27           recvbuf[sz] = 0;
  28           std::ostringstream sout;
  29           sout << '[' << boost::this_thread::get_id()
  30                << "] Received: " << recvbuf.get()
  31                << " from client: " << *epPtr << '\n';
  32           std::cout << sout.str() << '\n';
  33           socket.async_send_to(
 34               asio::buffer(recvbuf.get(), sz),
  35               *epPtr,
 36               this, recvbuf, epPtr {
  38               });
  39        });
  40 }

现在,我们不再依赖于一个共享的成员变量作为缓冲区,而是为每个新消息分配一个接收缓冲区(第 18 行)。这消除了列表 11.18 中buffer成员变量的需要。我们使用boost::shared_array包装器,因为这个缓冲区需要从waitForReceive调用传递到接收处理程序,而且只有在最后一个引用消失时才应该释放它。同样,我们移除了代表远程端点的remote_peer成员变量,并为每个新请求使用了一个shared_ptr包装的端点。

我们将底层数组传递给async_receive_from(第 21 行),并通过在async_receive_from的完成处理程序中捕获其shared_array包装器(第 23 行)来确保它存活足够长的时间。出于同样的原因,我们还捕获端点包装器epPtr。接收处理程序调用waitForReceive(第 25 行),然后打印从客户端接收到的消息,并在当前线程的线程 ID 前加上前缀(考虑未来)。然后它调用async_send_to,传递接收到的缓冲区而不是一些固定的消息(第 34 行)。再一次,我们需要确保缓冲区和远程端点在发送完成之前存活;因此,我们在发送完成处理程序中捕获了缓冲区的shared_array包装器和远程端点的shared_ptr包装器(第 36 行)。

基于协程的异步 UDP 服务器的更改(列表 11.19)也是在同样的基础上进行的。

 1 #include <boost/shared_array.hpp>
...
14 void udp_send_to(boost::asio::yield_context yield,
15               shared_udp_socket socket,
16               asio::ip::udp::endpoint peer,
17               boost::shared_array<char> buffer, size_t size)
18 {
19     const char *msg = "hello from server";
20     socket->async_send_to(asio::buffer(msg, std::strlen(msg)),
21                          peer, yield);
22     socket->async_send_to(asio::buffer(buffer.get(), size),
23                           peer, yield);
24 }
25
26 void udp_server(boost::asio::yield_context yield,
27                 asio::io_service& service,
28                 unsigned short port)
29 {
30   shared_udp_socket socket =
31       boost::make_shared<asio::ip::udp::socket>(service,
32           asio::ip::udp::endpoint(asio::ip::udp::v4(), port));
33
34   asio::ip::udp::endpoint remote_peer;
35   boost::system::error_code ec;
36
38   while (true) {
39     boost::shared_array<char> buffer(new char[MAXBUF]);
40     size_t size = socket->async_receive_from(
41                       asio::buffer(buffer.get(), MAXBUF),
42                       remote_peer, yield[ec]);
43
44     if (!ec) {
45       spawn(socket->get_io_service(), 
46         boost::bind(udp_send_to, ::_1, socket, remote_peer,
47                                  buffer, size));
43     }
44   }
45 }

由于需要将从客户端接收的数据回显回去,udp_send_to协程必须访问它。因此,它将包含接收到的数据的缓冲区和读取的字节数作为参数(第 17 行)。为了确保这些数据不会被后续接收覆盖,我们必须在udp_server循环的每次迭代中为接收数据分配缓冲区(第 39 行)。我们将这个缓冲区,以及async_receive_from返回的读取的字节数(第 40 行),传递给udp_send_to(第 47 行)。通过这些更改,我们的异步 UDP 服务器现在可以在响应对等方之前保持每个传入请求的上下文,而无需延迟处理新请求的需要。

这些更改还使处理程序线程安全,因为实质上,我们删除了处理程序之间的任何共享数据。虽然io_service仍然是共享的,但它是一个线程安全的对象。我们可以很容易地将 UDP 服务器转换为多线程服务器。下面是我们如何做到这一点:

46 int main() {
47   asio::io_service service;
48   UDPAsyncServer server(service, 55000);
49
50   boost::thread_group pool;
51   pool.create_thread([&service] { service.run(); });
52   pool.create_thread([&service] { service.run(); });
53   pool.create_thread([&service] { service.run(); });
54   pool.create_thread([&service] { service.run(); });
55   pool.join_all();
56 }

这将创建四个处理传入 UDP 消息的工作线程。使用协程也可以实现相同的功能。

TCP

在网络 I/O 方面,UDP 的编程模型非常简单——你要么发送消息,要么接收消息,要么两者都做。相比之下,TCP 是一个相当复杂的东西,它的交互模型有一些额外的细节需要理解。

除了可靠性保证外,TCP 还实现了几个巧妙的算法,以确保过于热心的发送方不会用大量数据淹没相对较慢的接收方(流量控制),并且所有发送方都能公平地分享网络带宽(拥塞控制)。TCP 层需要进行相当多的计算来实现这一切,并且需要维护一些状态信息来执行这些计算。为此,TCP 使用端点之间的连接

建立 TCP 连接

TCP 连接由一对 TCP 套接字组成,可能位于不同主机上,通过 IP 网络连接,并带有一些相关的状态数据。相关的连接状态信息在连接的每一端都得到维护。TCP 服务器通常开始监听传入连接,被称为连接的被动端TCP 客户端发起连接到 TCP 服务器的请求,并被称为主动端。一个被称为TCP 三次握手的明确定义的机制用于建立 TCP 连接。类似的机制也存在于协调连接终止。连接也可以被单方面重置或终止,比如在应用程序或主机因各种原因关闭或发生不可恢复的错误的情况下。

客户端和服务器端的调用

要建立 TCP 连接,服务器进程必须在一个端点上监听,并且客户端进程必须主动发起到该端点的连接。服务器执行以下步骤:

  1. 创建一个 TCP 监听套接字。

  2. 为监听传入连接创建一个本地端点,并将 TCP 监听套接字绑定到该端点。

  3. 开始在监听器上监听传入的连接。

  4. 接受任何传入的连接,并打开一个服务器端点(与监听端点不同)来服务该连接。

  5. 在该连接上进行通信。

  6. 处理连接的终止。

  7. 继续监听其他传入的连接。

客户端依次执行以下步骤:

  1. 创建一个 TCP 套接字,并可选地将其绑定到本地端点。

  2. 连接到由 TCP 服务器提供服务的远程端点。

  3. 一旦连接建立,就在该连接上进行通信。

  4. 处理连接的终止。

同步 TCP 客户端和服务器

我们现在将编写一个 TCP 客户端,它连接到指定主机和端口上的 TCP 服务器,向服务器发送一些文本,然后从服务器接收一些消息:

清单 11.20:同步 TCP 客户端

 1 #include <boost/asio.hpp>
 2 #include <iostream>
 3 namespace asio = boost::asio;
 4
 5 int main(int argc, char* argv[]) {
 6   if (argc < 3) {
 7     std::cerr << "Usage: " << argv[0] << " host port\n";
 8     exit(1);
 9   }
10
11   const char *host = argv[1], *port = argv[2];
12
13   asio::io_service service;
14   asio::ip::tcp::resolver resolver(service);
15   try {
16     asio::ip::tcp::resolver::query query(asio::ip::tcp::v4(),
17                                        host, port);
18     asio::ip::tcp::resolver::iterator end, 
19                        iter = resolver.resolve(query);
20
21     asio::ip::tcp::endpoint server(iter->endpoint());
22     std::cout << "Connecting to " << server << '\n';
23     asio::ip::tcp::socket socket(service, 
24                                  asio::ip::tcp::v4());
25     socket.connect(server);
26     std::string message = "Hello from client";
27     asio::write(socket, asio::buffer(message.c_str(),
28                                    message.size()));
29     socket.shutdown(asio::ip::tcp::socket::shutdown_send);
30 
31     char msg[BUFSIZ];
32     boost::system::error_code ec;
33     size_t sz = asio::read(socket, 
34                          asio::buffer(msg, BUFSIZ), ec);
35     if (!ec || ec == asio::error::eof) {
36       msg[sz] = 0;
37       std::cout << "Received: " << msg << '\n';
38     } else {
39       std::cerr << "Error reading response from server: "
40                 << ec.message() << '\n';
41     }
34   } catch (std::exception& e) {
35     std::cerr << e.what() << '\n';
36   }
37 }

TCP 客户端解析传递给它的主机和端口(或服务名称)(第 16-19 行),并创建一个表示要连接的服务器的端点(第 21 行)。它创建一个 IPv4 套接字(第 23 行),并调用connect成员函数来启动与远程服务器的连接(第 25 行)。connect调用会阻塞,直到建立连接,或者如果连接尝试失败则抛出异常。连接成功后,我们使用boost::asio::write函数将文本Hello from client发送到服务器(第 27-28 行)。我们调用套接字的shutdown成员函数,参数为shutdown_send(第 29 行),关闭与服务器的写通道。这在服务器端显示为 EOF。然后我们使用read函数接收服务器发送的任何消息(第 33-34 行)。boost::asio::writeboost::asio::read都是阻塞调用。对于失败的write调用会抛出异常,例如,如果连接被重置或由于服务器繁忙而发送超时。我们调用read的非抛出重载,在失败时,它会将我们传递给它的错误代码设置为非 const 引用。

函数boost::asio::read尝试读取尽可能多的字节以填充传递的缓冲区,并阻塞,直到所有数据到达或接收到文件结束符。虽然文件结束符被read标记为错误条件,但它可能只是表示服务器已经完成发送数据,我们对接收到的任何数据感兴趣。因此,我们特别使用read的非抛出重载,并在error_code引用中设置错误时,区分文件结束符和其他错误(第 35 行)。出于同样的原因,我们调用shutdown关闭此连接的写通道(第 29 行),以便服务器不等待更多输入。

提示

与 UDP 不同,TCP 是面向流的,并且不定义消息边界。应用程序必须定义自己的机制来识别消息边界。一些策略包括在消息前面加上消息的长度,使用字符序列作为消息结束标记,或者使用固定长度的消息。在本书的示例中,我们使用tcp::socketshutdown成员函数,这会导致接收方读取文件结束符,表示我们已经完成发送消息。这使示例保持简单,但实际上,这不是最灵活的策略。

现在让我们编写 TCP 服务器,它将处理来自此客户端的请求:

清单 11.21:同步 TCP 服务器

 1 #include <boost/asio.hpp>
 2 #include <boost/thread.hpp>
 3 #include <boost/shared_ptr.hpp>
 4 #include <boost/array.hpp>
 5 #include <iostream>
 6 namespace asio = boost::asio;
 7
 8 typedef boost::shared_ptr<asio::ip::tcp::socket> socket_ptr;
 9
10 int main() {
11   const unsigned short port = 56000;
12   asio::io_service service;
13   asio::ip::tcp::endpoint endpoint(asio::ip::tcp::v4(), port);
14   asio::ip::tcp::acceptor acceptor(service, endpoint);
15
16   while (true) {
17     socket_ptr socket(new asio::ip::tcp::socket(service));
18     acceptor.accept(*socket);
19     boost::thread([socket]() {
20       std::cout << "Service request from "
21                 << socket->remote_endpoint() << '\n';
22       boost::array<asio::const_buffer, 2> bufseq;
23       const char *msg = "Hello, world!";
24       const char *msg2 = "What's up?";
25       bufseq[0] = asio::const_buffer(msg, strlen(msg));
26       bufseq[1] = asio::const_buffer(msg2, strlen(msg2));
27 
28       try {
29         boost::system::error_code ec;
30         char recvbuf[BUFSIZ];
31         auto sz = read(*socket, asio::buffer(recvbuf,
32                                             BUFSIZ), ec);
33         if (!ec || ec == asio::error::eof) {
34           recvbuf[sz] = 0;
35           std::cout << "Received: " << recvbuf << " from "
36                     << socket->remote_endpoint() << '\n';
37           write(*socket, bufseq);
38           socket->close();
39         }
40       } catch (std::exception& e) {
41         std::cout << "Error encountered: " << e.what() << '\n';
42       }
43     });
44   }
45 }

TCP 服务器的第一件事是创建一个监听套接字并将其绑定到本地端点。使用 Boost Asio,您可以通过创建asio::ip::tcp::acceptor的实例并将其传递给要绑定的端点来实现这一点(第 14 行)。我们创建一个 IPv4 端点,只指定端口而不指定地址,以便使用未指定地址 0.0.0.0(第 13 行)。我们通过将其传递给acceptor的构造函数将端点绑定到监听器(第 14 行)。然后我们在循环中等待传入的连接(第 16 行)。我们需要一个独立的套接字来作为每个新连接的服务器端点,因此我们创建一个新的套接字(第 17 行)。然后我们在接受器上调用accept成员函数(第 18 行),将新套接字传递给它。accept调用会阻塞,直到建立新连接。当accept返回时,传递给它的套接字表示建立的连接的服务器端点。

我们创建一个新线程来为每个建立的新连接提供服务(第 19 行)。我们使用 lambda(第 19-44 行)生成此线程的初始函数,捕获此连接的shared_ptr包装的服务器端socket(第 19 行)。在线程内部,我们调用read函数来读取客户端发送的数据(第 31-32 行),然后使用write写回数据(第 37 行)。为了展示如何做到这一点,我们从两个字符字符串设置的多缓冲序列中发送数据(第 22-26 行)。此线程中的网络 I/O 在 try 块内完成,以确保没有异常逃逸出线程。请注意,在write返回后我们在 socket 上调用close(第 38 行)。这关闭了服务器端的连接,客户端在接收流中读取到文件结束符。

并发和性能

TCP 服务器独立处理每个连接。但是为每个新连接创建一个新线程的扩展性很差,如果在非常短的时间内有大量连接到达服务器,服务器的资源可能会耗尽。处理这种情况的一种方法是限制线程数量。之前,我们修改了清单 11.18 中的 UDP 服务器示例,使用了线程池并限制了总线程数量。我们可以对清单 11.21 中的 TCP 服务器做同样的事情。以下是如何实现的概述:

12 asio::io_service service;
13 boost::unique_ptr<asio::io_service::work> workptr(
14                                    new dummyWork(service));
15 auto threadFunc = [&service] { service.run(); };
16 
17 boost::thread_group workers;
18 for (int i = 0; i < max_threads; ++i) { //max_threads
19   workers.create_thread(threadFunc);
20 }
21
22 asio::ip::tcp::endpoint ep(asio::ip::tcp::v4(), port);
23 asio::ip::tcp::acceptor acceptor(service, ep);24 while (true) {
25   socket_ptr socket(new asio::ip::tcp::socket(service));
26   acceptor.accept(*socket);
27
28   service.post([socket] { /* do I/O on the connection */ });
29 }
30
31 workers.join_all();
32 workptr.reset(); // we don't reach here

首先,我们创建了一个固定数量线程的线程池(第 15-20 行),并通过向io_service的任务队列发布一个虚拟工作(第 13-14 行)来确保它们不会退出。我们不是为每个新连接创建一个线程,而是将连接的处理程序发布到io_service的任务队列中(第 28 行)。这个处理程序可以与清单 11.21 中每个连接线程的初始函数完全相同。然后线程池中的线程按照自己的时间表分派处理程序。max_threads表示的线程数量可以根据系统中的处理器数量轻松调整。

虽然使用线程池限制了线程数量,但对于服务器的响应性几乎没有改善。在大量新连接涌入时,新连接的处理程序会在队列中形成一个大的积压,这些客户端将被保持等待,而服务器则服务于先前的连接。我们已经通过使用异步 I/O 在 UDP 服务器中解决了类似的问题。在下一节中,我们将使用相同的策略来更好地扩展我们的 TCP 服务器。

异步 TCP 服务器

同步 TCP 服务器效率低下主要是因为套接字上的读写操作会阻塞一段有限的时间,等待操作完成。在此期间,即使有线程池,服务连接的线程也只是空闲地等待 I/O 操作完成,然后才能处理下一个可用连接。

我们可以使用异步 I/O 来消除这些空闲等待。就像我们在异步 UDP 服务器中看到的那样,我们可以使用处理程序链或协程来编写异步 TCP 服务器。虽然处理程序链使代码复杂,因此容易出错,但协程使代码更易读和直观。我们将首先使用协程编写一个异步 TCP 服务器,然后再使用更传统的处理程序链,以便更好地理解这两种方法之间的差异。在第一次阅读时,您可以跳过处理程序链的实现。

使用协程的异步 TCP 服务器

以下是使用协程进行异步 I/O 的 TCP 服务器的完整代码:

清单 11.22:使用协程的异步 TCP 服务器

 1 #include <boost/asio.hpp>
 2 #include <boost/asio/spawn.hpp>
 3 #include <boost/thread.hpp>
 4 #include <boost/shared_ptr.hpp>
 5 #include <boost/make_shared.hpp>
 6 #include <boost/bind.hpp>
 7 #include <boost/array.hpp>
 8 #include <iostream>
 9 #include <cstring>
10
11 namespace asio = boost::asio;
12 typedef boost::shared_ptr<asio::ip::tcp::socket> socketptr;
13
14 void handle_connection(asio::yield_context yield,
15                        socketptr socket)
16 {
17   asio::io_service& service = socket->get_io_service();
18   char msg[BUFSIZ];
19   msg[0] = '\0';
20   boost::system::error_code ec;
21   const char *resp = "Hello from server";
22
23   size_t size = asio::async_read(*socket, 
24                      asio::buffer(msg, BUFSIZ), yield[ec]);
25
26   if (!ec || ec == asio::error::eof) {
27     msg[size] = '\0';
28     boost::array<asio::const_buffer, 2> bufseq;
29     bufseq[0] = asio::const_buffer(resp, ::strlen(resp));
30     bufseq[1] = asio::const_buffer(msg, size);
31
32     asio::async_write(*socket, bufseq, yield[ec]);
33     if (ec) {
34       std::cerr << "Error sending response to client: "
35                 << ec.message() << '\n';
36     }
37   } else {
38     std::cout << ec.message() << '\n';
39   }
40 }
41
42 void accept_connections(asio::yield_context yield,
43                         asio::io_service& service,
44                         unsigned short port)
45 {
46   asio::ip::tcp::endpoint server_endpoint(asio::ip::tcp::v4(),
47                                           port);
48   asio::ip::tcp::acceptor acceptor(service, server_endpoint);
49
50   while (true) {
51     auto socket = 
52         boost::make_shared<asio::ip::tcp::socket>(service);
53     acceptor.async_accept(*socket, yield);
54
55     std::cout << "Handling request from client\n";
56     spawn(service, boost::bind(handle_connection, ::_1, 
57                                socket));
58   }
59 }
60
61 int main() {
62   asio::io_service service;
63   spawn(service, boost::bind(accept_connections, ::_1,
64                              boost::ref(service), 56000));
65   service.run();
66 }

我们使用了两个协程:accept_connections处理传入的连接请求(第 42 行),而handle_connection在每个新连接上执行 I/O(第 14 行)。main函数调用spawn函数模板将accept_connections任务添加到io_service队列中,以作为协程运行(第 63 行)。spawn函数模板可通过头文件boost/asio/spawn.hpp(第 2 行)获得。调用io_servicerun成员函数会调用accept_connections协程,该协程在一个循环中等待新的连接请求(第 65 行)。

accept_connections函数除了强制的yield_context之外,还接受两个参数。这些是对io_service实例的引用,以及用于监听新连接的端口——在main函数在生成此协程时绑定的值(第 63-64 行)。accept_connections函数为未指定的 IPv4 地址和传递的特定端口创建一个端点(第 46-47 行),并为该端点创建一个接受者(第 48 行)。然后,在循环的每次迭代中调用接受者的async_accept成员函数,传递一个 TCP 套接字的引用,并将本地的yield_context作为完成处理程序(第 53 行)。这会暂停accept_connections协程,直到接受到新的连接。一旦接收到新的连接请求,async_accept接受它,将传递给它的套接字引用设置为新连接的服务器端套接字,并恢复accept_connections协程。accept_connections协程将handle_connection协程添加到io_service队列中,用于处理此特定连接的 I/O(第 56-57 行)。在下一次循环迭代中,它再次等待新的传入连接。

handle_connection协程除了yield_context之外,还接受一个包装在shared_ptr中的 TCP 套接字作为参数。accept_connections协程创建此套接字,并将其包装在shared_ptr中传递给handle_connectionhandle_connection函数使用async_read接收客户端发送的任何数据(第 23-24 行)。如果接收成功,它会发送一个响应字符串Hello from server,然后使用长度为 2 的缓冲区序列回显接收到的数据(第 28-30 行)。

没有协程的异步 TCP 服务器

现在我们来看如何编写一个没有协程的异步 TCP 服务器。这涉及处理程序之间更复杂的握手,因此,我们希望将代码拆分成适当的类。我们在两个单独的头文件中定义了两个类。TCPAsyncServer类(清单 11.23)表示监听传入连接的服务器实例。它放在asyncsvr.hpp头文件中。TCPAsyncConnection类(清单 11.25)表示单个连接的处理上下文。它放在asynconn.hpp头文件中。

TCPAsyncServer为每个新的传入连接创建一个新的TCPAsyncConnection实例。TCPAsyncConnection实例从客户端读取传入数据,并向客户端发送消息,直到客户端关闭与服务器的连接。

要启动服务器,您需要创建一个TCPAsyncServer实例,传递io_service实例和端口号,然后调用io_servicerun成员函数来开始处理新连接:

清单 11.23:异步 TCP 服务器(asyncsvr.hpp)

 1 #ifndef ASYNCSVR_HPP
 2 #define ASYNCSVR_HPP
 3 #include <boost/asio.hpp>
 4 #include <boost/shared_ptr.hpp>
 5 #include <boost/make_shared.hpp>
 6 #include <iostream>
 7 #include "asynconn.hpp"
 8
 9 namespace asio = boost::asio;
10 namespace sys = boost::system;
11 typedef boost::shared_ptr<TCPAsyncConnection>
12               TCPAsyncConnectionPtr;
13
14 class TCPAsyncServer {
15 public:
16   TCPAsyncServer(asio::io_service& service, unsigned short p)
17           : acceptor(service,
18                     asio::ip::tcp::endpoint(
19                           asio::ip::tcp::v4(), p)) {
20     waitForConnection();
21   }
22
23   void waitForConnection() {
24     TCPAsyncConnectionPtr connectionPtr = boost::make_shared
25           <TCPAsyncConnection>(acceptor.get_io_service());
26     acceptor.async_accept(connectionPtr->getSocket(),
27           this, connectionPtr {
28             if (ec) {
29               std::cerr << "Failed to accept connection: "
30                         << ec.message() << "\n";
31             } else {
32               connectionPtr->waitForReceive();
33               waitForConnection();
34             }
35           });
36   }
37
38 private:
39   asio::ip::tcp::acceptor acceptor;
40 };
41
42 #endif /* ASYNCSVR_HPP */

TCPAsyncServer类具有一个boost::asio::ip::tcp::acceptor类型的接受者成员变量,用于监听和接受传入连接(第 39 行)。构造函数使用未指定的 IPv4 地址和特定端口初始化接受者(第 17-19 行),然后调用waitForConnection成员函数(第 20 行)。

waitForConnection函数创建了一个新的TCPAsyncConnection实例,将其包装在名为connectionPtrshared_ptr中(第 24-25 行),以处理来自客户端的每个新连接。我们已经包含了我们自己的头文件asynconn.hpp来访问TCPAsyncConnection的定义(第 7 行),我们很快就会看到。然后调用 acceptor 的async_accept成员函数来监听新的传入连接并接受它们(第 26-27 行)。我们传递给async_accept一个对TCPAsyncConnectiontcp::socket对象的非 const 引用,以及一个在每次建立新连接时调用的完成处理程序(第 27-35 行)。这是一个异步调用,会立即返回。但每次建立新连接时,套接字引用都会设置为用于服务该连接的服务器端套接字,并调用完成处理程序。

async_accept的完成处理程序被编写为 lambda,并捕获指向TCPAsyncServer实例的this指针和connectionPtr(第 27 行)。这允许 lambda 在TCPAsyncServer实例和为该特定连接提供服务的TCPAsyncConnection实例上调用成员函数。

提示

lambda 表达式生成一个函数对象,并将捕获的connectionPtr复制到其中的一个成员。由于connectionPtr是一个shared_ptr,在此过程中它的引用计数会增加。async_accept函数将此函数对象推送到io_service的任务处理程序队列中,因此TCPAsyncConnection的底层实例会在waitForConnection返回后继续存在。

在连接建立时,当调用完成处理程序时,它会执行两件事。如果没有错误,它会通过在TCPAsyncConnection对象上调用waitForReceive函数(第 32 行)来启动新连接上的 I/O。然后通过调用TCPAsyncServer对象上的waitForConnection(通过捕获的this指针)来重新等待下一个连接(第 33 行)。如果出现错误,它会打印一条消息(第 29-30 行)。waitForConnection调用是异步的,我们很快就会发现waitForReceive调用也是异步的,因为两者都调用了异步 Asio 函数。处理程序返回后,服务器将继续处理现有连接上的 I/O 或接受新连接:

清单 11.24:运行异步服务器

 1 #include <boost/asio.hpp>
 2 #include <boost/thread.hpp>
 3 #include <boost/shared_ptr.hpp>
 4 #include <iostream>
 5 #include "asyncsvr.hpp"
 6 #define MAXBUF 1024
 7 namespace asio = boost::asio;
 8
 9 int main() {
10   try {
11     asio::io_service service;
12     TCPAsyncServer server(service, 56000);
13     service.run();
14   } catch (std::exception& e) {
15     std::cout << e.what() << '\n';
16   }
17 }

要运行服务器,我们只需用io_service和端口号实例化它(第 12 行),然后在io_service上调用run方法(第 13 行)。我们正在构建的服务器将是线程安全的,因此我们也可以从线程池中的每个线程调用run,以在处理传入连接时引入一些并发。现在我们将看到如何处理每个连接上的 I/O:

清单 11.25:每个连接的 I/O 处理程序类(asynconn.hpp)

 1 #ifndef ASYNCONN_HPP
 2 #define ASYNCONN_HPP
 3
 4 #include <boost/asio.hpp>
 5 #include <boost/thread.hpp>
 6 #include <boost/shared_ptr.hpp>
 7 #include <iostream>
 8 #define MAXBUF 1024
 9
10 namespace asio = boost::asio;
11 namespace sys = boost::system;
12
13 class TCPAsyncConnection
14   : public boost::enable_shared_from_this<TCPAsyncConnection> {
15 public:
16   TCPAsyncConnection(asio::io_service& service) :
17       socket(service) {}
18
19   asio::ip::tcp::socket& getSocket() {
20     return socket;
21   }
22
23   void waitForReceive() {
24     auto thisPtr = shared_from_this();
25     async_read(socket, asio::buffer(buf, sizeof(buf)),
26         thisPtr {
27           if (!ec || ec == asio::error::eof) {
28             thisPtr->startSend();
29             thisPtr->buf[sz] = '\0'; 
30             std::cout << thisPtr->buf << '\n';
31             
32             if (!ec) { thisPtr->waitForReceive(); }
33           } else {
34             std::cerr << "Error receiving data from "
35                     "client: " << ec.message() << "\n";
36           }
37         });
38   }
39
40   void startSend() {
41     const char *msg = "Hello from server";
42     auto thisPtr = shared_from_this();
43     async_write(socket, asio::buffer(msg, strlen(msg)),
44         thisPtr {
45           if (ec) {
46             if (ec == asio::error::eof) {
47                thisPtr->socket.close();
48             }
49             std::cerr << "Failed to send response to "
50                     "client: " << ec.message() << '\n';
51           }
52         });
53   }
54
55 private:
56   asio::ip::tcp::socket socket;
57   char buf[MAXBUF];
58 };
59
60 #endif /* ASYNCONN_HPP */

我们在 11.23 清单中看到了如何创建TCPAsyncConnection的实例,并将其包装在shared_ptr中,以处理每个新连接,并通过调用waitForReceive成员函数来启动 I/O。现在让我们来了解它的实现。TCPAsyncConnection有两个用于在连接上执行异步 I/O 的公共成员:waitForReceive用于执行异步接收(第 23 行),startSend用于执行异步发送(第 40 行)。

waitForReceive函数通过在套接字上调用async_read函数(第 25 行)来启动接收。数据被接收到buf成员中(第 57 行)。此调用的完成处理程序(第 26-37 行)在数据完全接收时被调用。如果没有错误,它调用startSend,它异步地向客户端发送一条消息(第 28 行),然后再次调用waitForReceive,前提是之前的接收没有遇到文件结尾(第 32 行)。因此,只要没有读取错误,服务器就会继续等待在连接上读取更多数据。如果出现错误,它会打印诊断消息(第 34-35 行)。

startSend函数使用async_write函数向客户端发送文本Hello from server。它的处理程序在成功时不执行任何操作,但在失败时打印诊断消息(第 49-50 行)。对于 EOF 写入错误,它关闭套接字(第 47 行)。

TCPAsyncConnection 的生命周期

每个TCPAsyncConnection实例需要在客户端保持连接到服务器的时间内存活。这使得将这个对象的范围绑定到服务器中的任何函数变得困难。这就是我们在shared_ptr中创建TCPAsyncConnection对象的原因,然后在处理程序 lambda 中捕获它。TCPAsyncConnection用于在连接上执行 I/O 的成员函数waitForReceivestartSend都是异步的。因此,它们在返回之前将处理程序推入io_service的任务队列。这些处理程序捕获了TCPAsyncConnectionshared_ptr包装实例,以保持实例在调用之间的存活状态。

为了使处理程序能够从waitForReceivestartSend中访问TCPAsyncConnection对象的shared_ptr包装实例,需要这些TCPAsyncConnection的成员函数能够访问它们被调用的shared_ptr包装实例。我们在第三章中学到的enable shared from this习惯用法,内存管理和异常安全,是为这种目的量身定制的。这就是我们将TCPAsyncConnectionenable_shared_from_this<TCPAsyncConnection>派生的原因。由于这个原因,TCPAsyncConnection继承了shared_from_this成员函数,它返回我们需要的shared_ptr包装实例。这意味着TCPAsyncConnection应该始终动态分配,并用shared_ptr包装,否则会导致未定义的行为。

这就是我们在waitForReceive(第 24 行)和startSend(第 42 行)中都调用shared_from_this的原因,它被各自的处理程序捕获(第 26 行,44 行)。只要waitForReceive成员函数从async_read(第 32 行)的完成处理程序中被调用,TCPAsyncConnection实例就会存活。如果在接收中遇到错误,要么是因为远程端点关闭了连接,要么是因为其他原因,那么这个循环就会中断。包装TCPAsyncConnection对象的shared_ptr不再被任何处理程序捕获,并且在作用域结束时被销毁,关闭连接。

性能和并发性

请注意,TCP 异步服务器的两种实现,使用和不使用协程,都是单线程的。然而,在任何实现中都没有线程安全问题,因此我们也可以使用线程池,每个线程都会在io_service上调用run

控制流的反转

编写异步系统的最大困难在于控制流的反转。要编写同步服务器的代码,我们知道必须按以下顺序调用操作:

  1. 在接收器上调用accept

  2. 在套接字上调用read

  3. 在套接字上调用write

我们知道accept仅在连接建立后才返回,因此可以安全地调用read。此外,read仅在读取所请求的字节数或遇到文件结束后才返回。因此,可以安全地调用write。与异步模型相比,这使得编写代码变得非常容易,但引入了等待,影响了我们处理其他等待连接的能力,同时我们的请求正在被处理。

我们通过异步 I/O 消除了等待,但在使用处理程序链接时失去了模型的简单性。由于我们无法确定地告诉异步 I/O 操作何时完成,因此我们要求io_service在我们的请求完成时运行特定的处理程序。我们仍然知道在之后执行哪个操作,但不再知道何时。因此,我们告诉io_service要运行什么,它使用来自操作系统的适当通知来知道何时运行它们。这种模型的最大挑战是在处理程序之间维护对象状态和管理对象生命周期。

通过允许将异步 I/O 操作的序列写入单个协程来消除这种控制流反转,该协程被挂起而不是等待异步操作完成,并在操作完成时恢复。这允许无等待逻辑,而不会引入处理程序链接的固有复杂性。

提示

在编写异步服务器时,始终优先使用协程而不是处理程序链接。

自测问题

对于多项选择题,选择所有适用的选项:

  1. io_service::dispatchio_service::post之间的区别是什么?

a. dispatch立即返回,而post在返回之前运行处理程序

b. post立即返回,而dispatch如果可以在当前线程上运行处理程序,或者它的行为类似于 post

c. post是线程安全的,而dispatch不是

d. post立即返回,而dispatch运行处理程序

  1. 当处理程序在分派时抛出异常会发生什么?

a. 这是未定义行为

b. 它通过调用std::terminate终止程序

c. 在分派处理程序的io_service上调用 run 将抛出异常。

d. io_service被停止

  1. 未指定地址 0.0.0.0(IPv4)或::/1(IPv6)的作用是什么?

a. 它用于与系统上的本地服务通信

b. 发送到此地址的数据包将被回显到发送方

c. 它用于向网络中的所有连接的主机进行广播

d. 它用于绑定到所有可用接口,而无需知道地址

  1. 以下关于 TCP 的哪些陈述是正确的?

a. TCP 比 UDP 更快

b. TCP 检测数据损坏但不检测数据丢失

c. TCP 比 UDP 更可靠

d. TCP 重新传输丢失或损坏的数据

  1. 当我们说特定函数,例如async_read是异步时,我们是什么意思?

a. 在请求的操作完成之前,该函数会返回

b. 该函数在不同的线程上启动操作,并立即返回

c. 请求的操作被排队等待由同一线程或另一个线程处理

d. 如果可以立即执行操作,则该函数执行该操作,否则返回错误

  1. 我们如何确保在调用异步函数之前创建的对象仍然可以在处理程序中使用?

a. 将对象设为全局。

b. 在处理程序中复制/捕获包装在shared_ptr中的对象。

c. 动态分配对象并将其包装在shared_ptr中。

d. 将对象设为类的成员。

总结

Asio 是一个设计良好的库,可用于编写快速、灵活的网络服务器,利用系统上可用的最佳异步 I/O 机制。它是一个不断发展的库,是提议在未来的 C++标准修订版中添加网络库的技术规范的基础。

在这一章中,我们学习了如何使用 Boost Asio 库作为任务队列管理器,并利用 Asio 的 TCP 和 UDP 接口编写可以在网络上通信的程序。使用 Boost Asio,我们能够突出显示网络编程的一些一般性问题,针对大量并发连接的扩展挑战,以及异步 I/O 的优势和复杂性。特别是,我们看到使用 stackful 协程相对于旧模型的处理程序链,使得编写异步服务器变得轻而易举。虽然我们没有涵盖 stackless 协程、ICMP 协议和串口通信等内容,但本章涵盖的主题应该为您提供了理解这些领域的坚实基础。

参考资料

附录 A:C++11 语言特性模拟

在本节中,我们将回顾一些 C++ 编程中的概念,这些概念在理解本书涵盖的几个主题中具有概念上的重要性。其中许多概念是作为 C++11 的一部分相对较新地引入的。我们将研究:RAII、复制和移动语义、auto、基于范围的 for 循环以及 C++11 异常处理增强。我们将看看如何在预 C++11 编译器下使用 Boost 库的部分来模拟这些特性。

RAII

C++ 程序经常处理系统资源,如内存、文件和套接字句柄、共享内存段、互斥锁等。有明确定义的原语,一些来自 C 标准库,还有更多来自本地系统编程接口,用于请求和释放这些资源。未能保证已获取资源的释放可能会对应用程序的性能和正确性造成严重问题。

C++ 对象 在堆栈上 的析构函数在堆栈展开时会自动调用。展开发生在由于控制达到作用域的末尾而退出作用域,或者通过执行 returngotobreakcontinue。由于抛出异常而导致作用域退出也会发生展开。在任何情况下,都保证调用析构函数。这个保证仅限于堆栈上的 C++ 对象。它不适用于堆上的 C++ 对象,因为它们不与词法作用域相关。此外,它也不适用于前面提到的资源,如内存和文件描述符,它们是平凡旧数据类型(POD 类型)的对象,因此没有析构函数。

考虑以下使用 new[]delete[] 运算符的 C++ 代码:

char *buffer = new char[BUFSIZ];
… …
delete [] buffer;

程序员小心释放了分配的缓冲区。但是,如果另一个程序员随意编写代码,在调用 newdelete 之间的某个地方退出作用域,那么 buffer 就永远不会被释放,您将会泄漏内存。异常也可能在介入的代码中出现,导致相同的结果。这不仅适用于内存,还适用于任何需要手动释放的资源,比如在这种情况下的 delete[]

这是我们可以利用在退出作用域时保证调用析构函数来保证资源的清理。我们可以创建一个包装类,其构造函数获取资源的所有权,其析构函数释放资源。几行代码可以解释这种通常被称为资源获取即初始化RAII的技术。

清单 A.1:RAII 的实际应用

 1 class String
 2 {
 3 public:
 4   String(const char *str = 0)
 5   {  buffer_ = dupstr(str, len_);  }
 6 
 7   ~String() { delete [] buffer_; }
 8
 9 private:
10   char *buffer_;
11   size_t len_;
12 };
13
14 // dupstr returns a copy of s, allocated dynamically.
15 //   Sets len to the length of s.
16 char *dupstr(const char *str, size_t& len) {
17   char *ret = nullptr;
18
19   if (!str) {
20     len = 0;
21     return ret;
22   }
23   len = strlen(str);
24   ret = new char[len + 1];
25   strncpy(ret, str, len + 1);
26
27   return ret;
28 }

String 类封装了一个 C 风格的字符串。我们在构造过程中传递了一个 C 风格的字符串,并且如果它不为空,它会在自由存储器上创建传递的字符串的副本。辅助函数 dupstr 使用 new[] 运算符(第 24 行)在自由存储器上为 String 对象分配内存。如果分配失败,operator new[] 抛出 std::bad_alloc,并且 String 对象永远不会存在。换句话说,资源获取必须成功才能使初始化成功。这是 RAII 的另一个关键方面。

我们在代码中使用 String 类,如下所示:

 {
   String favBand("Led Zeppelin");
 ...   ...
 } // end of scope. favBand.~String() called.

我们创建了一个名为 favBandString 实例,它在内部动态分配了一个字符缓冲区。当 favBand 正常或由于异常而超出范围时,它的析构函数被调用并释放这个缓冲区。您可以将这种技术应用于所有需要手动释放的资源形式,并且它永远不会让资源泄漏。String 类被认为拥有缓冲区资源,即它具有独占所有权语义

复制语义

一个对象在其数据成员中保留状态信息,这些成员本身可以是 POD 类型或类类型。如果你没有为你的类定义一个复制构造函数,那么编译器会隐式为你定义一个。这个隐式定义的复制构造函数依次复制每个成员,调用类类型成员的复制构造函数,并对 POD 类型成员执行位拷贝。赋值运算符也是如此。如果你没有定义自己的赋值运算符,编译器会生成一个,并执行成员逐个赋值,调用类类型成员对象的赋值运算符,并对 POD 类型成员执行位拷贝。

以下示例说明了这一点:

清单 A.2:隐式析构函数、复制构造函数和赋值运算符

 1 #include <iostream>
 2
 3 class Foo {
 4 public:
 5   Foo() {}
 6
 7   Foo(const Foo&) {
 8     std::cout << "Foo(const Foo&)\n";
 9   }
10
11   ~Foo() {
12     std::cout << "~Foo()\n";
13   }
14
15   Foo& operator=(const Foo&) {
16     std::cout << "operator=(const Foo&)\n";
17     return *this;
18   }
19 };
20
21 class Bar {
22 public:
23   Bar() {}
24
25 private:
26   Foo f;
27 };
28
29 int main() {
30   std::cout << "Creating b1\n";
31   Bar b1;
32   std::cout << "Creating b2 as a copy of b1\n";
33   Bar b2(b1);
34
35   std::cout << "Assigning b1 to b2\n";
36   b2 = b1;
37 }

Bar包含类Foo的一个实例作为成员(第 25 行)。类Foo定义了一个析构函数(第 11 行),一个复制构造函数(第 7 行)和一个赋值运算符(第 15 行),每个函数都打印一些消息。类Bar没有定义任何这些特殊函数。我们创建了一个名为b1Bar实例(第 30 行),以及b1的一个副本b2(第 33 行)。然后我们将b1赋值给b2(第 36 行)。当程序运行时,输出如下:

Creating b1
Creating b2 as a copy of b1
Foo(const Foo&)
Assigning b1 to b2
operator=(const Foo&)
~Foo()
~Foo()

通过打印的消息,我们可以追踪从Bar的隐式生成的特殊函数调用Foo的特殊函数。

这对所有情况都有效,除了当你在类中封装指针或非类类型句柄到某些资源时。隐式定义的复制构造函数或赋值运算符将复制指针或句柄,但不会复制底层资源,生成一个浅复制的对象。这很少是需要的,这就是需要用户定义复制构造函数和赋值运算符来定义正确的复制语义的地方。如果这样的复制语义对于类没有意义,复制构造函数和赋值运算符应该被禁用。此外,您还需要使用 RAII 来管理资源的生命周期,因此需要定义一个析构函数,而不是依赖于编译器生成的析构函数。

有一个众所周知的规则叫做三规则,它规范了这个常见的习惯用法。它说如果你需要为一个类定义自己的析构函数,你也应该定义自己的复制构造函数和赋值运算符,或者禁用它们。我们在 A.1 清单中定义的String类就是这样一个候选者,我们将很快添加剩下的三个规范方法。正如我们所指出的,并不是所有的类都需要定义这些函数,只有封装资源的类才需要。事实上,建议使用这些资源的类应该与管理这些资源的类不同。因此,我们应该为每个资源创建一个包装器,使用专门的类型来管理这些资源,比如智能指针(第三章,“内存管理和异常安全性”),boost::ptr_container(第五章,“超出 STL 的有效数据结构”),std::vector等等。使用资源的类应该有包装器而不是原始资源作为成员。这样,使用资源的类就不必再担心管理资源的生命周期,隐式定义的析构函数、复制构造函数和赋值运算符对它的目的就足够了。这就被称为零规则

不抛出交换

感谢零规则,你应该很少需要担心三规则。但是当你确实需要使用三规则时,有一些细枝末节需要注意。让我们首先了解如何在 A.1 清单中为String类定义一个复制操作:

清单 A.1a:复制构造函数

 1 String::String(const String &str) : buffer_(0), len_(0)
 2 {
 3   buffer_ = dupstr(str.buffer_, len_);
 4 }

复制构造函数的实现与清单 A.1 中的构造函数没有区别。赋值运算符需要更多的注意。考虑以下示例中如何对String对象进行赋值:

 1 String band1("Deep Purple");
 2 String band2("Rainbow");
 3 band1 = band2;

在第 3 行,我们将band2赋值给band1。作为此过程的一部分,应释放band1的旧状态,然后用band2的内部状态的副本进行覆盖。问题在于复制band2的内部状态可能会失败,因此在成功复制band2的状态之前,不应销毁band1的旧状态。以下是实现此目的的简洁方法:

清单 A.1b:赋值运算符

 1 String& String::operator=(const String& rhs)
 2 {
 3   String tmp(rhs);   // copy the rhs in a temp variable
 4   swap(tmp);         // swap tmp's state with this' state.
 5   return *this;      // tmp goes out of scope, releases this'
 6                      // old state
 7 }

我们将tmp作为rhs的副本创建(第 3 行),如果此复制失败,它应该抛出异常,赋值操作将失败。被赋值对象this的内部状态不应更改。对swap的调用(第 4 行)仅在复制成功时执行(第 3 行)。对swap的调用交换了thistmp对象的内部状态。因此,this现在包含rhs的副本,而tmp包含this的旧状态。在此函数结束时,tmp超出范围并释放了this的旧状态。

提示

通过考虑特殊情况,可以进一步优化此实现。如果被赋值对象(左侧)已经具有至少与rhs的内容相同大小的存储空间,那么我们可以简单地将rhs的内容复制到被赋值对象中,而无需额外的分配和释放。

这是swap成员函数的实现:

清单 A.1c:nothrow swap

 1 void String::swap(String&rhs) noexcept
 2 {
 3   using std::swap;
 3   swap(buffer_, rhs.buffer_);
 4   swap(len_, rhs.len_);
 5 }

交换原始类型变量(整数、指针等)不应引发任何异常,这一事实我们使用 C++11 关键字noexcept来宣传。我们可以使用throw()代替noexcept,但异常规范在 C++11 中已被弃用,而noexceptthrow()子句更有效。这个swap函数完全是用交换原始数据类型来写的,保证成功并且永远不会使被赋值对象处于不一致的状态。

移动语义和右值引用

复制语义用于创建对象的克隆。有时很有用,但并非总是需要或有意义。考虑封装 TCP 客户端套接字的以下类。TCP 套接字是一个整数,表示 TCP 连接的一个端点,通过它可以向另一个端点发送或接收数据。TCP 套接字类可以有以下接口:

class TCPSocket
{
public:
  TCPSocket(const std::string& host, const std::string& port);
  ~TCPSocket();

  bool is_open();
  vector<char> read(size_t to_read);
  size_t write(vector<char> payload);

private:
  int socket_fd_;

  TCPSocket(const TCPSocket&);
  TCPSocket& operator = (const TCPSocket&);
};

构造函数打开到指定端口上主机的连接并初始化socket_fd_成员变量。析构函数关闭连接。TCP 不定义一种克隆打开套接字的方法(不像具有dup/dup2的文件描述符),因此克隆TCPSocket也没有意义。因此,通过将复制构造函数和复制赋值运算符声明为私有来禁用复制语义。在 C++11 中,这样做的首选方法是将这些成员声明为已删除:

TCPSocket(const TCPSocket&) = delete;
TCPSocket& operator = (const TCPSocket&) = delete;

虽然不可复制,但在一个函数中创建TCPSocket对象然后返回给调用函数是完全合理的。考虑一个创建到某个远程 TCP 服务的连接的工厂函数:

TCPSocket connectToService()
{
  TCPSocket socket(get_service_host(),  // function gets hostname
                   get_service_port()); // function gets port
  return socket;
}

这样的函数将封装关于连接到哪个主机和端口的详细信息,并创建一个要返回给调用者的TCPSocket对象。这实际上根本不需要复制语义,而是需要移动语义,在connectToService函数中创建的TCPSocket对象的内容将被转移到调用点的另一个TCPSocket对象中:

TCPSocket socket = connectToService();

在 C++03 中,如果不启用复制构造函数,将无法编写此代码。我们可以通过曲线救国复制构造函数来提供移动语义,但这种方法存在许多问题:

TCPSocket::TCPSocket(TCPSocket& that) {
  socket_fd_ = that.socket_fd_;
  that.socket_fd_ = -1;
}

请注意,这个“复制”构造函数实际上将其参数的内容移出,这就是为什么参数是非 const 的原因。有了这个定义,我们实际上可以实现connectToService函数,并像之前那样使用它。但是没有什么可以阻止以下情况发生:

 1 void performIO(TCPSocket socket)
 2 {
 3   socket.write(...);
 4   socket.read(...);
 5   // etc.
 6 }
 7
 8 TCPSocket socket = connectToService();
 9 performIO(socket);   // moves TCPSocket into performIO
10 // now socket.socket_fd_ == -1
11 performIO(socket);   // OOPs: not a valid socket

我们通过调用connectToService(第 8 行)获得了名为socketTCPSocket实例,并将此实例传递给performIO(第 9 行)。但用于将socket按值传递给performIO的复制构造函数移出了其内容,当performIO返回时,socket不再封装有效的 TCP 套接字。通过将移动伪装成复制,我们创建了一个令人费解且容易出错的接口;如果您熟悉std::auto_ptr,您以前可能已经见过这种情况。

右值引用

为了更好地支持移动语义,我们必须首先回答一个问题:哪些对象可以被移动?再次考虑TCPSocket示例。在函数connectToService中,表达式TCPSocket(get_service_host(), get_service_port())TCPSocket无名临时对象,其唯一目的是传递到调用者的上下文。没有人可以在创建该语句之后引用此对象。从这样的对象中移出内容是完全合理的。但在以下代码片段中:

TCPSocket socket = connectToService();
performIO(socket);

socket对象中移出内容是危险的,因为在调用上下文中,对象仍然绑定到名称socket,并且可以在进一步的操作中使用。表达式socket被称为左值表达式——具有标识并且其地址可以通过在表达式前加上&-运算符来获取。非左值表达式被称为右值表达式。这些是无名表达式,其地址不能使用&-运算符在表达式上计算。例如,TCPSocket(get_service_host(), get_service_port())是一个右值表达式。

一般来说,从左值表达式中移动内容是危险的,但从右值表达式中移动内容是安全的。因此,以下是危险的:

TCPSocket socket = connectToService();
performIO(socket);

但以下是可以的:

performIO(connectToService());

请注意,表达式connectToService()不是左值表达式,因此符合右值表达式的条件。为了区分左值和右值表达式,C++11 引入了一种新的引用类别,称为右值引用,它可以引用右值表达式但不能引用左值表达式。这些引用使用双和符号的新语法声明,如下所示:

socket&& socketref = TCPSocket(get_service_host(), 
                               get_service_port());

早期被简单称为引用的引用的另一类现在称为左值引用。非 const 左值引用只能引用左值表达式,而 const 左值引用也可以引用右值表达式:

/* ill-formed */
socket& socketref = TCPSocket(get_service_host(), 
                              get_service_port());

/* well-formed */
const socket& socketref = TCPSocket(get_service_host(), 
                                    get_service_port());

右值引用可以是非 const 的,通常是非 const 的:

socket&& socketref = TCPSocket(...);
socketref.read(...);

在上面的代码片段中,表达式socketref本身是一个左值表达式,因为可以使用&-运算符计算其地址。但它绑定到一个右值表达式,并且通过非 const 右值引用引用的对象可以通过它进行修改。

右值引用重载

我们可以根据它们是否接受左值表达式或右值表达式来创建函数的重载。特别是,我们可以重载复制构造函数以接受右值表达式。对于TCPSocket类,我们可以编写以下内容:

TCPSocket(const TCPSocket&) = delete;

TCPSocket(TCPSocket&& rvref) : socket_fd_(-1)
{
  std::swap(socket_fd_, rvref.socket_fd_);
}

虽然左值重载是删除的复制构造函数,但右值重载被称为移动构造函数,因为它被实现为篡夺或“窃取”传递给它的右值表达式的内容。它将源的内容移动到目标,将源(rvref)留在某种未指定的状态中,可以安全地销毁。在这种情况下,这相当于将rvrefsocket_fd_成员设置为-1。

使用移动构造函数的定义,TCPSocket 可以移动,但不能复制。connectToService 的实现将正常工作:

TCPSocket connectToService()
{
  return TCPSocket(get_service_host(),get_service_port());
}

这将把临时对象移回到调用者。但是,对 performIO 的后续调用将是不合法的,因为 socket 是一个左值表达式,而 TCPSocket 仅为其定义了需要右值表达式的移动语义:

TCPSocket socket = connectToService();
performIO(socket);

这是一个好事,因为您不能移动像 socket 这样的对象的内容,而您可能稍后还会使用它。可移动类型的右值表达式可以通过值传递,因此以下内容将是合法的:

performIO(connectToService());

请注意,表达式 connectToService() 是一个右值表达式,因为它未绑定到名称,其地址也无法被获取。

类型可以既可复制又可移动。例如,我们可以为 String 类实现一个移动构造函数,除了它的复制构造函数:

 1 // move-constructor
 2 String::String(String&& source) noexcept
 3       : buffer_(0), len_(0)
 4 {
 5   swap(source); // See listing A.1c
 6 }

nothrow swap 在移动语义的实现中起着核心作用。源对象和目标对象的内容被交换。因此,当源对象在调用范围内超出范围时,它释放其新内容(目标对象的旧状态)。目标对象继续存在,具有其新状态(源对象的原始状态)。移动是基于 nothrow swap 实现的,它只交换原始类型的指针和值,并且保证成功;因此,使用了 noexcept 说明。实际上,移动对象通常需要更少的工作,涉及交换指针和其他数据位,而复制通常需要可能失败的新分配。

移动赋值

就像我们可以通过窃取另一个对象的内容来构造对象一样,我们也可以在两者都构造之后将一个对象的内容移动到另一个对象。为此,我们可以定义一个移动赋值运算符,即复制赋值运算符的右值重载:

 1 // move assignment
 2 String& String::operator=(String&& rhs) noexcept
 3 {
 4   swap(rhs);
 5   return *this;
 6 }

或者,我们可以定义一个通用赋值运算符,适用于左值和右值表达式:

 1 // move assignment
 2 String& String::operator=(String rhs)
 3 {
 4   swap(rhs);
 5   return *this;
 6 }

请注意,通用赋值运算符不能与左值或右值重载共存,否则在重载解析中会存在歧义。

xvalues

当您使用右值表达式调用函数时,如果有可用的右值重载函数,则编译器会将函数调用解析为右值重载函数。但是,如果您使用命名变量调用函数,则会将其解析为左值重载(如果有的话),否则程序将是不合法的。现在,您可能有一个命名变量,可以从中移动,因为您以后不需要使用它:

void performIO(TCPSocket socket);

TCPSocket socket = connectToService();
// do stuff on socket
performIO(socket);  // ill-formed because socket is lvalue

前面的示例将无法编译,因为 performIO 以值传递其唯一参数,而 socket 是一个仅移动类型,但它不是右值表达式。通过使用 std::move,您可以将左值表达式转换为右值表达式,并将其传递给期望右值表达式的函数。std::move 函数模板在标准头文件 utility 中定义。

#include <utility> // for std::moves
performIO(std::move(socket));

std::move(socket) 的调用给我们一个对 socket 的右值引用;它不会导致任何数据从 socket 中移出。当我们将这种右值引用类型的表达式传递给以值传递其参数的函数 performIO 时,在 performIO 函数中创建了一个新的 TCPSocket 对象,对应于其按值参数。它是从 socket 进行移动初始化的,也就是说,它的移动构造函数窃取了 socket 的内容。在调用 performIO 后,变量 socket 失去了其内容,因此不应在后续操作中使用。如果 TCPSocket 的移动构造函数正确实现,那么 socket 应该仍然可以安全地销毁。

表达式 std::move(socket) 共享 socket 的标识,但它可能在传递给函数时被移动。这种表达式称为xvaluesx 代表 expired

提示

xvalues像 lvalues 一样有明确定义的标识,但可以像 rvalues 一样移动。xvalues绑定到函数的 rvalue 引用参数。

如果performIO没有按值接受其参数,而是按 rvalue-reference,那么有一件事会改变:

void performIO(TCPSocket&& socket);
performIO(std::move(socket));

performIO(std::move(socket))的调用仍然是良好的形式,但不会自动移出socket的内容。这是因为我们在这里传递了一个现有对象的引用,而当我们按值传递时,我们创建了一个从socket移动初始化的新对象。在这种情况下,除非performIO函数的实现明确地移出socket的内容,否则在调用performIO之后,它仍将在调用上下文中保持有效。

提示

一般来说,如果您将对象转换为 rvalue 表达式并将其传递给期望 rvalue 引用的函数,您应该假设它已经被移动,并且在调用之后不再使用它。

如果 T 类型的对象是函数内部的本地对象,并且 T 具有可访问的移动或复制构造函数,则可以从该函数中返回该对象的值。如果有移动构造函数,则返回值将被移动初始化,否则将被复制初始化。但是,如果对象不是函数内部的本地对象,则必须具有可访问的复制构造函数才能按值返回。此外,编译器在可能的情况下会优化掉复制和移动。

考虑connectToService的实现以及它的使用方式:

 1 TCPSocket connectToService()
 2 {
 3   return TCPSocket(get_service_host(),get_service_port());
 4 }
 5
 6 TCPSocket socket = connectToService();

在这种情况下,编译器实际上会直接在socket对象的存储空间(第 3 行)中构造临时对象,而connectToService的返回值原本是要移动到的地方(第 6 行)。这样,它会简单地优化掉socket的移动初始化(第 6 行)。即使移动构造函数具有副作用,这种优化也会生效,这意味着这些副作用可能不会因此优化而产生效果。同样,编译器可以优化掉复制初始化,并直接在目标位置构造返回的对象。这被称为返回值优化RVO),自 C++03 以来一直是所有主要编译器的标准,当时它只优化了复制。尽管在 RVO 生效时不会调用复制或移动构造函数,但它们仍然必须被定义和可访问才能使 RVO 生效。

当返回 rvalue 表达式时,RVO 适用,但是即使从函数中返回了命名的本地堆栈对象,编译器有时也可以优化掉复制或移动。这被称为命名返回值优化NRVO)。

返回值优化是复制省略的一个特例,其中编译器优化掉 rvalue 表达式的移动或复制,直接在目标存储中构造它:

std::string reverse(std::string input);

std::string a = "Hello";
std::string b = "World";
reverse(a + b);

在前面的示例中,表达式a + b是一个 rvalue 表达式,它生成了一个std::string类型的临时对象。这个对象不会被复制到函数reverse中,而是省略了复制,并且由表达式a + b生成的对象会直接在reverse的参数的存储空间中构造。

提示

通过值传递和返回类型 T 的对象需要为 T 定义移动或复制语义。如果有移动构造函数,则使用它,否则使用复制构造函数。在可能的情况下,编译器会优化掉复制或移动操作,并直接在调用或被调用函数的目标位置构造对象。

使用 Boost.Move 进行移动模拟

在本节中,我们将看看如何使用 Boost.Move 库相对容易地为自己的传统类实际上实现了大部分移动语义的后期改造。首先,考虑 C++ 11 语法中String类的接口:

 1 class String
 2 {
 3 public:
 4   // Constructor
 5   String(const char *str = 0);
 6
 7   // Destructor
 8   ~String();
 9
10   // Copy constructor
11   String(const String& that);
12
13   // Copy assignment operator
14   String& operator=(const String& rhs);
15
16   // Move constructor
17   String(String&& that);
18
19   // Move assignment
20   String& operator=(String&& rhs);
2122 };

现在让我们看看如何使用 Boost 的工具定义等效的接口:

清单 A.2a:使用 Boost.Move 进行移动模拟

 1 #include <boost/move/move.hpp>
 2 #include <boost/swap.hpp>
 3
 4 class String {
 5 private:
 6   BOOST_COPYABLE_AND_MOVABLE(String);
 7
 8 public:
 9   // Constructor
10   String(const char *str = 0);
11
12   // Destructor
13   ~String();
14
15   // Copy constructor
16   String(const String& that);
17
18   // Copy assignment operator
19   String& operator=(BOOST_COPY_ASSIGN_REF(String) rhs);
20
21   // Move constructor
22   String(BOOST_RV_REF(String) that);
23
24   // Move assignment
25   String& operator=(BOOST_RV_REF(String) rhs);
26 
27   void swap(String& rhs);
28
29 private:
30   char *buffer_;
31   size_t size_;
32 };

关键更改如下:

  • 第 6 行:宏BOOST_COPYABLE_AND_MOVABLE(String)定义了一些内部基础设施,以支持String类型的拷贝和移动语义,并区分String类型的左值和右值。这被声明为私有。

  • 第 19 行:一个拷贝赋值运算符,它接受类型BOOST_COPY_ASSIGN_REF(String)。这是String的包装类型,可以隐式转换为String的左值。

  • 第 22 行和 25 行:接受包装类型BOOST_RV_REF(String)的移动构造函数和移动赋值运算符。String的右值隐式转换为此类型。

  • 请注意,第 16 行的拷贝构造函数不会改变。

在 C++ 03 编译器下,移动语义的模拟是在没有语言或编译器的特殊支持的情况下提供的。使用 C++ 11 编译器,宏自动使用 C++ 11 本机构造来支持移动语义。

实现与 C++ 11 版本基本相同,只是参数类型不同。

清单 A.2b:使用 Boost Move 进行移动模拟

 1 // Copy constructor
 2 String::String(const String& that) : buffer_(0), len_(0)
 3 {
 4   buffer_ = dupstr(that.buffer_, len_);
 5 }
 6 
 7 // Copy assignment operator
 8 String& String::operator=(BOOST_COPY_ASSIGN_REF(String)rhs)
 9 {
10   String tmp(rhs);
11   swap(tmp);        // calls String::swap member
12   return *this;
13 }
14 
15 // Move constructor
16 String::String(BOOST_RV_REF(String) that) : buffer_(0), 
17                                             size_(0) 
18 { 
19   swap(that);      // calls String::swap member 
20 }
21 // Move assignment operator
22 String& String::operator=(BOOST_RV_REF(String)rhs)
23 {
24   swap(rhs);
25   String tmp;
26   rhs.swap(tmp);
27
28   return *this;
29 }
30 
31 void String::swap(String& that)
32 {
33   boost::swap(buffer_, that.buffer_);
34   boost::swap(size_, that.size_);
35 }

如果我们只想使我们的类支持移动语义而不支持拷贝语义,那么我们应该使用宏BOOST_MOVABLE_NOT_COPYABLE代替BOOST_COPYABLE_AND_MOVABLE,并且不应该定义拷贝构造函数和拷贝赋值运算符。

在拷贝/移动赋值运算符中,如果需要,我们可以通过将执行交换/复制的代码放在 if 块内来检查自赋值,如下所示:

if (this != &rhs) {}

只要拷贝/移动的实现是异常安全的,这不会改变代码的正确性。但是,通过避免对自身进行赋值的进一步操作,可以提高性能。

因此,总之,以下宏帮助我们在 C++ 03 中模拟移动语义:

#include <boost/move/move.hpp>

BOOST_COPYABLE_AND_MOVABLE(classname)
BOOST_MOVABLE_BUT_NOT_COPYABLE(classname)
BOOST_COPY_ASSIGN_REF(classname)
BOOST_RV_REF(classname)

除了移动构造函数和赋值运算符之外,还可以使用BOOST_RV_REF(…)封装类型作为其他成员方法的参数。

如果要从左值移动,自然需要将其转换为“模拟右值”的表达式。您可以使用boost::move来实现这一点,它对应于 C++ 11 中的std::move。以下是使用 Boost 移动模拟在String对象上调用不同的拷贝和移动操作的一些示例:

 1 String getName();                       // return by value
 2 void setName(BOOST_RV_REF(String) str); // rvalue ref overload
 3 void setName(const String&str);        // lvalue ref overload
 4 
 5 String str1("Hello");                 
 6 String str2(str1);                      // copy ctor
 7 str2 = getName();                       // move assignment
 8 String str3(boost::move(str2));         // move ctor
 9 String str4;
10 str4 = boost::move(str1);               // move assignment
11 setName(String("Hello"));               // rvalue ref overload
12 setName(str4);                          // lvalue ref overload
13 setName(boost::move(str4));             // rvalue ref overload

C++11 auto 和 Boost.Auto

考虑如何声明指向字符串向量的迭代器:

std::vector<std::string> names;
std::vector<std::string>::iterator iter = vec.begin();

iter的声明类型很大且笨重,每次显式写出来都很麻烦。鉴于编译器知道右侧初始化表达式的类型,即vec.begin(),这也是多余的。从 C++11 开始,您可以使用auto关键字要求编译器使用初始化表达式的类型来推导已声明变量的类型。因此,前面的繁琐被以下内容替换:

std::vector<std::string> names;
auto iter = vec.begin();

考虑以下语句:

auto var = expr;

当使用参数expr调用以下函数模板时,var的推导类型与推导类型T相同:

template <typename T>
void foo(T);

foo(expr);

类型推导规则

有一些规则需要记住。首先,如果初始化表达式是引用,则在推导类型中引用被剥离:

int x = 5;
int& y = x;
auto z = y;  // deduced type of z is int, not int&

如果要声明左值引用,必须将auto关键字明确加上&,如下所示:

int x = 5;
auto& y = x;     // deduced type of y is int&

如果初始化表达式不可复制,必须以这种方式使被赋值者成为引用。

第二条规则是,初始化表达式的constvolatile限定符在推导类型中被剥离,除非使用auto声明的变量被显式声明为引用:

int constx = 5;
auto y = x;     // deduced type of y is int
auto& z = x;    // deduced type of z is constint

同样,如果要添加constvolatile限定符,必须显式这样做,如下所示:

intconst x = 5;
auto const y = x;    // deduced type of y is constint

常见用法

auto关键字在许多情况下非常方便。它让您摆脱了不得不输入长模板 ID 的困扰,特别是当初始化表达式是函数调用时。以下是一些示例,以说明其优点:

auto strptr = boost::make_shared<std::string>("Hello");
// type of strptr is boost::shared_ptr<std::string>

auto coords(boost::make_tuple(1.0, 2.0, 3.0));
// type of coords is boost::tuple<double, double, double>

请注意通过使用auto实现的类型名称的节省。另外,请注意,在创建名为coordstuple时,我们没有使用赋值语法进行初始化。

Boost.Auto

如果您使用的是 C++11 之前的编译器,可以使用BOOST_AUTOBOOST_AUTO_TPL宏来模拟这种效果。因此,您可以将最后一小节写成如下形式:

#include <boost/typeof/typeof.hpp>

BOOST_AUTO(strptr, boost::make_shared<std::string>("Hello"));
// type of strptr is boost::shared_ptr<std::string>

BOOST_AUTO(coords, boost::make_tuple(1.0, 2.0, 3.0));
// type of coords is boost::tuple<double, double, double>

请注意需要包含的头文件boost/typeof/typeof.hpp以使用该宏。

如果要声明引用类型,可以在变量前加上引导符号(&)。同样,要为变量添加constvolatile限定符,应在变量名之前添加constvolatile限定符。以下是一个示例:

BOOST_AUTO(const& strptr, boost::make_shared<std::string>("Hello"));
// type of strptr is boost::shared_ptr<std::string>

基于范围的 for 循环

基于范围的 for 循环是 C++11 中引入的另一个语法便利。基于范围的 for 循环允许您遍历值的序列,如数组、容器、迭代器范围等,而无需显式指定边界条件。它通过消除了需要指定边界条件来使迭代更不容易出错。

基于范围的 for 循环的一般语法是:

for (range-declaration : sequence-expression) {
 statements;
}

序列表达式标识要遍历的值序列,如数组或容器。范围声明标识一个变量,该变量将在循环的连续迭代中代表序列中的每个元素。基于范围的 for 循环自动识别数组、大括号包围的表达式序列和具有返回前向迭代器的beginend成员函数的容器。要遍历数组中的所有元素,可以这样写:

T arr[N];
...
for (const auto& elem : arr) {
  // do something on each elem
}

您还可以遍历大括号括起来的表达式序列:

for (const auto& elem: {"Aragorn", "Gandalf", "Frodo Baggins"}) {
  // do something on each elem
}

遍历通过beginend成员函数公开前向迭代器的容器中的元素并没有太大不同:

std::vector<T> vector;
...
for (const auto& elem: vector) {
  // do something on each elem
}

范围表达式使用auto声明了一个名为elem的循环变量来推断其类型。基于范围的 for 循环中使用auto的这种方式是惯用的和常见的。要遍历封装在其他类型对象中的序列,基于范围的 for 循环要求两个命名空间级别的方法beginend可用,并且可以通过参数相关查找(见第二章,Boost 实用工具的第一次接触)来解析。基于范围的 for 循环非常适合遍历在遍历期间长度保持不变的序列。

Boost.Foreach

您可以使用BOOST_FOREACH宏来模拟 C++11 基于范围的 for 循环的基本用法:

#include <boost/foreach.hpp>

std::vector<std::string> names;
...
BOOST_FOREACH(std::string& name, names) {
  // process each elem
}

在前面的示例中,我们使用BOOST_FOREACH宏来遍历名为names的字符串向量的元素,使用名为namestring类型的循环变量。使用BOOST_FOREACH,您可以遍历数组、具有返回前向迭代器的成员函数beginend的容器、迭代器对和以空字符结尾的字符数组。请注意,C++11 基于范围的 for 循环不容易支持最后两种类型的序列。另一方面,使用BOOST_FOREACH,您无法使用auto关键字推断循环变量的类型。

C++11 异常处理改进

C++11 引入了捕获和存储异常的能力,可以在稍后传递并重新抛出。这对于在线程之间传播异常特别有用。

存储和重新抛出异常

为了存储异常,使用类型std::exception_ptrstd::exception_ptr是一种具有共享所有权语义的智能指针类型,类似于std::shared_ptr(参见第三章,“内存管理和异常安全性”)。std::exception_ptr的实例是可复制和可移动的,并且可以传递给其他函数,可能跨线程。默认构造的std::exception_ptr是一个空对象,不指向任何异常。复制std::exception_ptr对象会创建两个管理相同底层异常对象的实例。只要包含它的最后一个exception_ptr实例存在,底层异常对象就会继续存在。

函数std::current_exception在 catch 块内调用时,返回执行该 catch 块的活动异常,包装在std::exception_ptr的实例中。在 catch 块外调用时,返回一个空的std::exception_ptr实例。

函数std::rethrow_exception接收一个std::exception_ptr的实例(不能为 null),并抛出std::exception_ptr实例中包含的异常。

清单 A.3:使用 std::exception_ptr

 1 #include <stdexcept>
 2 #include <iostream>
 3 #include <string>
 4 #include <vector>
 5
 6 void do_work()
 7 {
 8   throw std::runtime_error("Exception in do_work");
 9 }
10
11 std::vector<std::exception_ptr> exceptions;
12
13 void do_more_work()
14 {
15   std::exception_ptr eptr;
16
17   try {
18     do_work();
19   } catch (...) {
20     eptr = std::current_exception();
21   }
22
23   std::exception_ptr eptr2(eptr);
24   exceptions.push_back(eptr);
25   exceptions.push_back(eptr2);
26 }
27
28 int main()
29 {
30   do_more_work();
31
32   for (auto& eptr: exceptions) try {
33     std::rethrow_exception(eptr);
34   } catch (std::exception& e) {
35     std::cout << e.what() << '\n';
36   }
37 }

运行上述示例会打印以下内容:

Exception in do_work
Exception in do_work

main函数调用do_more_work(第 30 行),然后调用do_work(第 18 行),后者只是抛出一个runtime_error异常(第 8 行),该异常最终到达do_more_work(第 19 行)中的 catch 块。我们在do_more_work(第 15 行)中声明了一个类型为std::exception_ptr的对象eptr,并在 catch 块内调用std::current_exception,并将结果赋给eptr。稍后,我们创建了eptr的副本(第 23 行),并将两个实例推入全局exception_ptr向量(第 24-25 行)。

main函数中,我们遍历全局向量中的exception_ptr实例,使用std::rethrow_exception(第 33 行)抛出每个异常,并捕获并打印其消息。请注意,在此过程中,我们打印相同异常的消息两次,因为我们有两个包含相同异常的exception_ptr实例。

使用 Boost 存储和重新抛出异常

在 C++11 之前的环境中,可以使用boost::exception_ptr类型来存储异常,并使用boost::rethrow_exception来抛出存储在boost::exception_ptr中的异常。还有boost::current_exception函数,其工作方式类似于std::current_exception。但是在没有底层语言支持的情况下,它需要程序员的帮助才能运行。

为了使boost::current_exception返回当前活动的异常,包装在boost::exception_ptr中,我们必须修改异常,然后才能抛出它,以便使用这种机制进行处理。为此,我们在要抛出的异常上调用boost::enable_current_exception。以下代码片段说明了这一点:

清单 A.4:使用 boost::exception_ptr

 1 #include <boost/exception_ptr.hpp>
 2 #include <iostream>
 3
 4 void do_work()
 5 {
 6   throw boost::enable_current_exception(
 7             std::runtime_error("Exception in do_work"));
 8 }
 9
10 void do_more_work()
11 {
12   boost::exception_ptr eptr;
13 
14   try {
15     do_work();
16   } catch (...) {
17     eptr = boost::current_exception();
18   }
19
20   boost::rethrow_exception(eptr);
21 }
22
23 int main() {
24   try {
25     do_more_work();
26   } catch (std::exception& e) {
27     std::cout << e.what() << '\n';
28   }
29 }

自测问题

  1. 三大法则规定,如果为类定义自己的析构函数,则还应定义:

a. 您自己的复制构造函数

b. 您自己的赋值运算符

c. 两者都是

d. 两者中的任意一个

  1. 假设类String既有复制构造函数又有移动构造函数,以下哪个不会调用移动构造函数:

a. String s1(getName());

b. String s2(s1);

c. String s2(std::move(s1));

d. String s3("Hello");

  1. std::move函数的目的是:

a. 移动其参数的内容

b. 从右值引用创建左值引用

c. 从左值表达式创建 xvalue

d. 交换其参数的内容与另一个对象

  1. 以下哪种情况适用于返回值优化?:

a. return std::string("Hello");

b. string reverse(string);string a, b;reverse(a + b);

c. std::string s("Hello");return s;

d. std::string a, b;return a + b.

参考资料

  • 《Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14Scott MeyersO’Reilly Media

  • 《C++之旅》,Bjarne Stroustrup,Addison Wesley Professional

  • 《C++程序设计语言(第 4 版)》,Bjarne Stroustrup,Addison Wesley Professional

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值