【中文】Using Asio with C++11

原文链接:《Using Asio with C++11》

本文既是对Asio库的介绍,也是对其实现和与C++11结合使用的简要概述。

1. Asio 库的 C++11 变体

本文不是使用 Asio 库的 Boost 发行版,而是一个独立于 Boost 的 Asio 变体。此变体的目标包括:

  • 在接口中只使用 C++11 语言特性;
  • 证明该库可以仅使用C++11标准库和操作系统提供的设施来实现。

这个变体的仓库地址在 https://github.com/chriskohlhoff/asio/tree/cpp11-only

2. 使用 I/O 流解决一些简单问题的用例

在许多应用程序中,网络不是一个核心功能,也不被视为应用程序开发者的核心能力。为了迎合这些用例,Asio 提供了一个高级的 TCP 套接字接口,它是围绕我们熟悉的 C++ I/O 流框架设计的。

使用这个库可以通过下面这种方式用远程主机的详细信息轻易地构造一个流对象:

asio::ip::tcp::iostream s("www.boost.org", "http");

接下来,决定是否要在主机无响应一段时间后放弃套接字:

s.expires_from_now(std::chrono::seconds(60));

然后,根据需要发送和接收任何数据。在这个例子里你可以发送如下请求:

s << "GET / HTTP/1.0\r0.2cm\n";
s << "Host: www.boost.org\r\n";
s << "Accept: */*\r\n";
s << "Connection: close\r\n\r\n";

最后接收并处理响应:

std::string header;
while (std::getline(s, header) && header != "\r")
    std::cout << header << "\n";
std::cout << s.rdbuf();

如果任何时候出现错误,tcp::iostream 类的 error() 成员函数可以用来确认错误的原因:

if (!s)
{
    std::cout << "Socket error: " << s.error().message() << "\n";
    return 1;
}

3. 了解同步操作

同步操作是在相应的操作系统操作完成之前不会将控制权返回给调用者的函数。在基于 Asio 的程序中它们的使用通常可以分为两类:

  • 不关心超时或依赖于底层操作系统提供的超时行为的简单程序。
  • 需要对系统调用进行细粒度控制的程序,并且知道同步操作会或不会阻塞的条件。

Asio 可用于对 I/O 对象(如套接字)执行同步和异步操作。然而,同步操作提供了一个机会,可以从概念上简单概述Asio的各个部分,你的程序以及它们如何协同工作。作为一个介绍性的示例,让我们考虑一下当您在套接字上执行同步连接操作时会发生什么。

你的程序将至少有一个 io_service 对象。这个 io_service 表示你的程序到操作系统 I/O 服务的链接。(补充:现在一般不用 io_service 而是 io_context ,但是实质上还是一样的)

asio::io_service io_service;

要执行 I/O 操作,你的程序将需要一个 I/O 对象,如一个 TCP 套接字:

asio::ip::tcp::socket socket(io_service);

当执行同步连接操作时,会发生以下事件序列:

【中文】Using Asio with C++11_第1张图片

  1. 你的程序通过调用 I/O 对象来启动连接操作:

    socket.connect(server_endpoint);
    
  2. I/O 对象会将请求转发到 io_service

  3. io_service 调用操作系统以执行连接操作。

  4. 操作系统会将操作的结果返回给 io_service

  5. io_service 将该操作导致的任何错误转换为 std::error_code 类型的对象。然后将结果转发回 I/O 对象。

  6. 如果操作失败,I/O 对象将抛出 std::system_error 类型的异常。如果启动操作的代码已经被写为:

    std::error_code ec;
    socket.connnect(server_endpoint, ec);
    

    就会将 error_code 变量 ec 设置为操作的结果,并且不会抛出任何异常。

4. 了解异步操作

异步操作不会阻塞调用者,而是在相应的操作系统操作完成时向程序发送通知。大多数基于 Asio 的非平凡程序将使用异步操作。

当使用异步操作时,会发生以下事件序列:

【中文】Using Asio with C++11_第2张图片

  1. 你的程序通过调用 I/O 对象来启动异步连接操作:

    socket.async_connect(server_endpoint, your_completion_handler)
    

    这个 async_connect() 函数是一个异步函数。异步函数在 Asio 中以前缀 async_ 命名。异步函数将函数对象(称为 completion handler)作为最后的参数。在这种特殊情况下,你的 your_completion_handler 是一个具有指定函数签名的函数或函数对象:

    void your_completion_handler(const std::error_code& ec);
    

    完成处理程序所需的确切签名取决于正在执行的异步操作。Asio 参考文档指出了每项操作的适当形式。

  2. I/O 对象会将请求转发到 io_service

  3. io_service 向操作系统发出信号,表明它应该启动一个异步连接操作。

时间过去了。(在同步的情况下,这种等待将完全包含在连接操作的持续时间内。)在此期间,操作系统在理论上负责异步操作,这被称为未完成的工作。

【中文】Using Asio with C++11_第3张图片

  1. 操作系统通过将结果放在一个队列中,准备好由 io_service 接受,来表示连接操作已经完成。
  2. 你的程序必须调用 io_service::run() (或一个类似的 io_service 成员函数)以便检索结果。当有未完成的工作时,对 io_service::run() 的调用将会阻塞。你通常会在开始第一个异步操作后立即调用它。
  3. 在对 io_service::run() 的调用中,io_service 出队操作的结果,将其转换为 error_code 然后将其传递给你的完成处理函数。

5. 连锁异步操作

一个异步操作被认为是未完成的工作,直到其关联的完成处理函数被调用并返回为止。完成处理函数可以反过来调用其它异步函数,从而创建更多未完成的工作。

考虑在连接之后,在套接字上执行其它 I/O 操作的情况:

socket.async_connect(server_endpoint, [&](std::error_code ec){
   if (!ec) {
       socket.async_read_some(
           asio::buffer(data),
           [&](std::error_code ec, std::size_t length){
               if (!ec) {
                   async_write(socket, asio::buffer(data, length),
                              [&](std::error_code ec, std::size_t length){
                                  //...
                              });
               }
           });
   }
});

异步连接操作的完成处理程序在这里表示为 C++11 的 lambda 函数,用于启动一个异步读取操作。它有自己相关的未完成工作,并且它的完成处理程序以异步写入的形式创建了更多的工作。因此,直到链中的所有操作都完成之前,io_service::run() 都不会返回。

当然,在实际的程序中,这些链通常更长,并且可能包含循环或分叉。在这些程序中,io_service::run() 可能无限执行下去。

6. 处理错误

Asio 的错误处理方法是基于这样一种观点:异常并不总是处理错误的正确办法。例如,在网络编程中,经常会遇到一些错误如:

  • 你无法连接到远程 IP 地址。
  • 你的链接已退出。
  • 你尝试打开一个 IPv6 套接字,但没有可用的 IPv6 网络接口。

这些可能是例外情况,但同样可以作为正常控制流程的一部分进行处理。如果你有理由认为它会发生,这并不例外。分别为:

  • IP 地址是与主机名对应的地址列表之一。你可能想尝试连接列表中的下一个地址。
  • 网络不可靠。你想尝试重新建立连接并只在失败 n 次后才放弃。
  • 你的程序可以返回到使用 IPv4 套接字。

错误是否异常取决于程序的上下文。此外,由于代码大小或性能限制,一些领域可能不愿意或无法使用异常。因此,所有同步操作都提供了抛出异常:

socket.connect(server_endpoint);	// 出错抛出 std::system_error 异常

和不抛异常的重载版本:

std::error_code ec;
socket.connect(server_endpoint, ec);	// 设置 ec 来表明错误

处于类似的原因,Asio 不会根据操作是否完成并出现错误而使用单独的完成处理程序。这样做将在异步操作链中创建一个分叉,该分叉可能与程序对什么构成错误条件的概念不匹配。

7. 管理对象生命周期

当使用异步操作时,一个特殊的挑战是对象生命周期管理。Asio 采用了一种没有明确支持管理对象生存期的方法。相反,根据基于如何声明启动函数的规则,将生命周期需求置于程序控制之下:

  • 按值,常量引用和右值引用参数。

    根据库实现的需要复制或移动这些参数,直到不再需要为止。例如,库实现维护完成处理程序对象的副本,直到调用处理程序之后。

  • 非常量引用参数,this 指针。

    程序负责确保该对象在异步操作之前一直保持有效。

许多基于 Asio 的程序采用的一种方法是将对象生存期与完成处理程序联系起来。这可以通过使用 std::shared_ptr<>std::enable_shared_from_this<>

class connection : 
    std::enable_shared_from_this
{
    asio::ip::tcp::socket socket_;
    std::vector data_;
    // ...
    void start_read()
    {
        socket_.async_read_some(
                    asio::buffer(data_),
                    std::bind(&connection::handle_read, shared_from_this(),
                              std::placeholders::_1, std::placeholders::_2));
    }
    // ...
    void handle_read(std::error_code ec, std::size_t length) {

    }
};

使用 C++11,这种方法可以在可用性和性能之间给出很好的权衡。Asio 能够利用可移动的完成处理程序来减少引用计数相关的开销。由于程序通常由异步操作链组成,指针的所有权可以沿着链转移;引用计数只在链的开始和结尾更新。

然而,有些程序需要对对象生命期,内存使用量和执行效率进行细粒度的控制。通过将对象生命周期置于程序控制之下,这些需求也被支持。例如,另一种方法是在程序启动时创建所有对象。完成处理程序不需要在对象生命周期中扮演角色,而且复制起来可能非常便宜。

8. 优化内存分配

许多异步操作需要分配一个对象类存储与该操作关联的状态。例如,Windows 实现需要 OVERLAPPED 派生对象类传递给 Win32 API 函数。

默认情况下,Asio 将使用 ::operator new() 为该记账信息分配空间。然而,异步操作链为这里提供了一个优化的机会。每个链可以有一个与之相关联的内存块,同一块可以重复用于链中的每个顺序操作。这意味着可以编写不执行持续内存分配的异步协议实现。

此自定义内存分配的钩子是异步操作的完成处理程序。处理程序标识正在执行操作的较大上下文。通过将这个完成处理程序传递给启动函数,Asio 能够在向操作系统发出启动异步操作的信号之前分配必要的内存。

9. 处理并发

协议实现通常涉及到多个异步操作链的协调。例如,一个操作链可以处理消息发送、另一个接收,而第三个操作链可以实现应用层超时。所有这些链都需要访问公共变量,如套接字、计时器和缓冲区。此外,这些异步操作可以无限期地继续进行。

异步操作提供了一种实现并发性的方法,而无需增加线程的开销和复杂性。然而,Asio 的接口被设计为支持一系列的线程方法,其中一些方法概述如下。

单线程设计

Asio 保证完成处理函数只能从 io_service::run() 内部调用。因此,通过仅从一个线程调用 io_service::run() ,可以防止处理程序的并行执行。这种设计是大多数程序的推荐起点,因为不需要显式的同步机制。但是,必须注意保持处理程序的简短和非阻塞性。

使用线程来执行长时间运行的任务

作为单线程设计的一个变体,这个设计仍然使用单个 io_service::run() 线程来实现协议逻辑。长时间运行或阻塞的任务将被传递给后台线程,一旦完成,结果将被发布回 io_service::run() 线程。

通过使用无共享消息传递方法(Asio 支持通过 io_service 的成员函数 post()dispatch() 进行消息传递),程序可以确保 io_service::run() 线程和任何后台工作线程之间不共享对象。因此,仍然不需要显式的同步。

多个 io_service,分别运行在单独的线程上

在这个设计中,I/O 对象被分配了一个 “家” io_service,它运行在一个单独的线程上。不同的对象应该只通过消息传递来进行通信。

这种设计可以更有效地使用多个CPU,同时限制争用资源。不需要显式同步,但处理程序必须保持短且非阻塞。(补充:这种模型在一些开源库有用到,例如 C++ http 框架 cinatra ,就用到了这种模型)

单个 io_service,多个线程

可以从多个线程调用 io_service::run() 函数来为单个 io_service 设置一个线程池。该实现以任意方式在线程之间分配可用的工作。

由于可以调用完成处理程序,因此可以从任何线程中调用,除非协议实现很简单,并且由单个操作链组成,否则可能需要某种形式的同步。Asio 为了这个目的提供了 io_service::strand 类。

strand 可以防止并发执行与之相关联的任何完成处理程序。在上面的示例中,我们有一个由三个操作链(用于发送、接收和超时)组成的协议实现,strand 确保相关链的处理程序被序列化。运行在其他链上的其他协议实现仍然能够利用池中的任何其他线程。此外,与互斥锁不同,如果一个strand “正在使用”,它不会阻塞调用线程,而是会切换到在其他线程上运行其他就绪处理程序,以保持线程繁忙。

与自定义内存分配一样,strand 同步使用与完成处理程序相关联的钩子。也就是说,完成处理程序标识正在执行操作的较大上下文。这个自定义调用钩子允许同步机制扩展到基于操作组合的抽象,我们将在下面看到。

(补充:这种模型使用起来CPU利用率比较高,但是异步任务会随机分配到空闲线程上执行,因此同步起来比较麻烦,推荐在调用链单一的情况下使用,具体写法可以参考我写的 Socks5 代理服务器 socks-server )

10. 传递责任:开发高效的抽象概念

Asio 的一个关键设计目标是支持创建更高层次的抽象。其主要机制是组成异步操作。在 Asio 的术语中,这些操作被简单地称为“组合操作”。

例如,考虑一个假设的用户定义的异步操作,该操作实现了从一个套接字读取到另一个套接字的所有数据的传递传输。启动函数可以声明如下:

template 
void async_transfer(asio::ip::tcp::socket& socket1, asio::ip::tcp::socket& socket2,
                    std::array& working_buffer,
                    Handler handler);

这个函数将通过两个底层的异步操作来实现:从 socket1 读取和写入 socket2 。每个这些操作都有一个中间完成处理程序,它们之间的关系如下图所示:

【中文】Using Asio with C++11_第4张图片

这些中间完成处理程序可以通过自定义分配和调用钩子来“传递责任”,从而简单地调用用户的完成处理程序钩子。通过这种方式,组合将内存分配和同步方面的所有选择延迟给抽象的用户。抽象的用户可以在易用性和效率之间选择适当的权衡,并且如果不需要显式同步,则不需要支付任何同步成本。

Asio提供了许多开箱即用的组合操作,例如非成员函数 async_connect()async_write()async_read_until() 。密切相关的组合操作也可以按对象分组,例如 Asio 的 buffered_stream<>ssl::stream<> 模板。

11. 范围和可扩展性

考虑到Asio库的规模,在 WG21 之前的提案被限制在最小可行子集,主要集中于与 TCP 和 UDP 、缓冲区和计时器的网络。然而,Asio 的接口设计允许用户和实现者通过多种机制进行可扩展或扩展。下面将描述其中的一些机制。

额外的 I/O 服务

io_service 类实现了一个可扩展的,类型安全的,多态的 I/O 服务集,并按服务类型进行索引。I/O 服务代表 I/O 对象管理操作系统的逻辑接口。特别是,有些资源可以在一类 I/O 对象之间共享。例如,计时器可以根据单个计时器队列来实现。I/O服务管理这些共享资源,并且被设计为在不使用时不产生任何成本。

Asio实现了额外的I/O服务,以提供对特定操作系统特定功能的访问。例如:

  • 用于在 HANDLE 上执行重叠 I/O 的 Windows 特定服务
  • 一种特定于 Windows 的服务,用于等待内核对象,如事件、进程和线程。
  • 面向流的文件描述符的 POSIX 特定服务
  • 通过 signal() 或 POSIX sigaction() 安全集成信号处理的服务。

可以添加新的I/O服务,而不影响库的现有用户。

套接字类型要求

尽管基于 Asio 的提议仅限于 TCP 和 UDP 套接字,但该接口是基于类型需求的,如 Protocol 和 Endpoint。这些类型要求旨在允许库使用其他类型的套接字。Asio 库本身已经使用了这些类型的要求来增加对 ICMP 和 UNIX 域套接字的支持。

流类型要求

Asio 库为面向同步和异步流的 I/O 定义了几种类型要求。这些类型的要求由 TCP 套接字接口实现,也可以通过抽象如 ssl::stream<>buffered_stream<> 。通过实现这些类型需求,一个类可以与组合的操作一起使用,比如 async_read()async_read_until()async_write() 。类型需求也打算用于更高层次的抽象中,例如在 HTTP 上的异步包装器。

你可能感兴趣的:(C++,c++,网络,开发语言)