Boost Asio 网络编程理论基础

基础概念


Asio命名空间

Boost.Asio的所有内容都包含在boost::asio命名空间或者其子命名空间内。

  • boost::asio:这是核心类和函数所在的地方。

    • 重要的类有io_service和streambuf。
    • 类似read, read_at, read_until方法,它们的异步方法、同步写方法等自由函数也在这里。
  • boost::asio::ip:这是网络通信部分所在的地方。

    • 重要的类有address, endpoint, tcp, udp和icmp,重要的自由函数有connect和async_connect。
    • 注意在boost::asio::ip::tcp::socket中间,socket只是boost::asio::ip::tcp类中间的一个typedef关键字。
  • boost::asio::error:这个命名空间包含了调用I/O例程时返回的错误码

  • boost::asio::ssl:包含了SSL处理类的命名空间

  • boost::asio::local:这个命名空间包含了POSIX特性的类

  • boost::asio::windows:这个命名空间包含了Windows特性的类

IP地址

Boost.Asio提供了ip::address , ip::address_v4和ip::address_v6类。 它们提供的重要函数如下:

  • ip::address(v4_or_v6_address):这个函数把一个v4或者v6的地址转换成ip::address
  • ip::address:from_string(str):这个函数根据一个IPv4地址(用.隔开的)或者一个IPv6地址(十六进制表示)创建一个地址。
  • ip::address::to_string() :这个函数返回这个地址的字符串。
  • ip::address_v4::broadcast([addr, mask]):这个函数创建了一个广播地址 ip::address_v4::any():这个函数返回一个能表示任意地址的地址。
  • ip::address_v4::loopback(), ip_address_v6::loopback():这个函数返回环路地址(为v4/v6协议)
  • ip::host_name():这个函数用string数据类型返回当前的主机名。

经常会使用的函数是ip::address::from_string

ip::address addr = ip::address::from_string("127.0.0.1"); // 正确
ip::address addr = ip::address::from_string("www.yahoo.com"); // 错误,异常
端点

端点是使用某个端口连接到的一个地址。

不同类型的socket有它自己的endpoint类,比如ip::tcp::endpoint、ip::udp::endpoint和ip::icmp::endpoint

例如创建一个本机80端口的端点:ip::tcp::endpoint ep( ip::address::from_string("127.0.0.1"), 80);

有三种方式建立端点:

endpoint():默认构造函数,可以用来创建UDP/ICMP socket。
endpoint(protocol, port):通常用来创建可以接受新连接的服务器端socket。
endpoint(addr, port): 创建了一个连接到某个地址和端口的端点。

示例如下:

// 创建endpoint的方法
ip::tcp::endpoint ep1;
ip::tcp::endpoint ep2(ip::tcp::v4(), 80);
ip::tcp::endpoint ep3( ip::address::from_string("127.0.0.1), 80);

// 连接到主机(不是IP地址),输出 "87.248.122.122"
io_service service;
ip::tcp::resolver resolver(service);
ip::tcp::resolver::query query("www.yahoo.com", "80");
ip::tcp::resolver::iterator iter = resolver.resolve( query);
ip::tcp::endpoint ep = *iter;
std::cout << ep.address().to_string() << std::endl;

首先,为要查询的名字创建一个查询器,然后用resolve()函数解析它。

如果成功,它至少会返回一个入口。可以利用返回的迭代器,使用第一个入口或者遍历整个列表来拿到全部的入口。

给定一个端点,可以获得他的地址,端口和IP协议(v4或者v6):std::cout << ep.address().to_string() << ":" << ep.port() << "/" << ep.protocol() << std::endl;

套接字

Boost.Asio有三种类型的套接字类:ip::tcp, ip::udp和ip::icmp。使用它们可以便捷地访问其类或者函数,如下:

  • ip::tcp::socket, ip::tcp::acceptor, ip::tcp::endpoint, ip::tcp::resolver, ip::tcp::iostream
  • ip::udp::socket, ip::udp::endpoint, ip::udp::resolver
  • ip::icmp::socket, ip::icmp::endpoint, ip::icmp::resolver

socket类创建一个相应的socket。而且总是在构造的时候传入io_service实例:

io_service service;
ip::udp::socket sock(service)
sock.set_option(ip::udp::socket::reuse_address(true));

socket成员方法


用于连接

这些方法是用来连接或绑定socket、断开socket字连接以及查询连接是活动还是非活动的:

  • assign(protocol,socket)
    • 分配一个原生的socket给这个socket实例。当处理老(旧)程序时会使用它(也就是说,原生socket已经被建立了)
  • open(protocol)
    • 用给定的IP协议(v4或者v6)打开一个socket。你主要在UDP/ICMP socket,或者服务端socket上使用。
  • bind(endpoint)
    • 绑定到一个地址
  • connect(endpoint)
    • 用同步的方式连接到一个地址
  • async_connect(endpoint)
    • 用异步的方式连接到一个地址
  • is_open()
    • 如果套接字已经打开,这个函数返回true
  • close()
    • 这个函数用来关闭套接字。调用时这个套接字上任何的异步操作都会被立即关闭,同时返回error::operation_aborted错误码。
  • shutdown(type_of_shutdown)
    • 这个函数立即使send或者receive操作失效,或者两者都失效。
  • cancel()
  • 这个函数取消套接字上所有的异步操作。这个套接字上任何的异步操作都会立即结束,然后返回error::operation_aborted错误码。

示例:

ip::tcp::endpoint ep( ip::address::from_string("127.0.0.1"), 80);
ip::tcp::socket sock(service);
sock.open(ip::tcp::v4());
sock.connect(ep);
sock.write_some(buffer("GET /index.html\r\n"));
char buff[1024]; 
sock.read_some(buffer(buff,1024));
sock.shutdown(ip::tcp::socket::shutdown_receive);
sock.close();
用于读写

在套接字上执行I/O操作的函数。分为同步函数和异步函数。

对于异步函数来说,处理程序的格式void handler(const boost::system::error_code& e, size_t bytes)都是一样的,如下:

  • async_receive(buffer, [flags,] handler)

    • 启动从套接字异步接收数据的操作。
  • async_read_some(buffer, handler)

    • 这个函数和async_receive(buffer, handler)功能一样。
  • async_receive_from(buffer, endpoint[, flags], handler)

    • 启动从一个指定端点异步接收数据的操作。
  • async_send(buffer [, flags], handler)

    • 启动一个异步发送缓冲区数据的操作。
  • async_write_some(buffer, handler)

    • 这个函数和async_send(buffer, handler)功能一致。
  • async_send_to(buffer, endpoint, handler)

    • 启动一个异步send缓冲区数据到指定端点的操作。
  • receive(buffer [, flags])

    • 这个函数同步地从所给的缓冲区读取数据。在读完所有数据或者错误出现之前,这个函数都是阻塞的。
  • read_some(buffer)

    • 这个函数的功能和receive(buffer)是一致的。
  • receive_from(buffer, endpoint [, flags])

    • 同步地从一个指定的端点获取数据并写入到给定的缓冲区。在读完所有数据或者错误出现之前,这个函数都是阻塞的。
  • send(buffer [, flags])

    • 同步地发送缓冲区的数据。在所有数据发送成功或者出现错误之前,这个函数都是阻塞的。
  • write_some(buffer)

    • 这个函数和send(buffer)的功能一致。
  • send_to(buffer, endpoint [, flags])

    • 同步地把缓冲区数据发送到一个指定的端点。在所有数据发送成功或者出现错误之前,这个函数都是阻塞的。
  • available()

    • 返回有多少字节的数据可以无阻塞地进行同步读取。

其中,buffer是数据缓冲区(很重要,稍后在一切中介绍),flags是标记,默认为0,可以是以下值:

  • ip::socket_type::socket::message_peek
    • 只监测并返回某个消息,但是下一次读消息的调用会重新读取这个消息。
  • ip::socket_type::socket::message_out_of_band
    • 处理带外(OOB)数据,OOB数据是被标记为比正常数据更重要的数据。
  • ip::socket_type::socket::message_do_not_route
    • 指定数据不使用路由表来发送。
  • ip::socket_type::socket::message_end_of_record
    • 指定的数据标识了记录的结束。在Windows下不支持。

常用的可能是message_peek,使用方法请参照下面的代码片段:

char buff[1024];
sock.receive(buffer(buff), ip::tcp::socket::message_peek );
memset(buff,1024, 0);
// 重新读取之前已经读取过的内容
sock.receive(buffer(buff) );
套接字控制

用来处理套接字的高级选项:

  • get_io_service()
    • 返回构造函数中传入的io_service实例
  • get_option(option)
    • 返回一个套接字的属性
  • set_option(option)
    • 设置一个套接字的属性
  • io_control(cmd)
    • 在套接字上执行一个I/O指令

可以获取/设置的套接字选项:

名字 描述 类型
reuse_address 如果为true,套接字能绑定到一个已用的地址 bool
send_buffer_size 套接字发送缓冲区大小 int
receive_buffer_size 套接字接收缓冲区大小 int
send_low_watermark 规定套接字数据发送的最小字节数 int
receive_low_watemark 规定套接字输入处理的最小字节数 int
ip::v6_only 如果为true,则只允许IPv6的连接 bool
linger 如果为true,套接字会在有未发送数据的情况下挂起close() bool
keep_alive 如果为true,会发送心跳 bool
do_not_route 如果为true,则阻止路由选择只使用本地接口 bool
enable_connection_aborted 如果为true,记录在accept()时中断的连接 bool
broadcast 如果为true,允许广播消息 bool
debug 如果为true,启用套接字级别的调试 bool

使用示例:

ip::tcp::endpoint ep( ip::address::from_string("127.0.0.1"), 80);
ip::tcp::socket sock(service);
sock.connect(ep);
// TCP套接字可以重用地址
ip::tcp::socket::reuse_address ra(true);
sock.set_option(ra);
// 获取套接字读取的数据
ip::tcp::socket::receive_buffer_size rbs;
sock.get_option(rbs);
std::cout << rbs.value() << std::endl;
// 把套接字的缓冲区大小设置为8192
ip::tcp::socket::send_buffer_size sbs(8192);
sock.set_option(sbs);
其他方法

  • local_endpoint()
    • 返回套接字本地连接的地址。
  • remote_endpoint()
    • 返回套接字连接到的远程地址。
  • native_handle()
    • 返回原始套接字的处理程序。你只有在调用一个Boost.Asio不支持的原始方法时才需要用到它。
  • non_blocking()
    • 如果套接字是非阻塞的,这个方法返回true,否则false。
  • native_non_blocking()
    • 如果套接字是非阻塞的,这个方法返回true,否则返回false。
    • 注意,它是基于原生的套接字来调用本地的api。所以通常来说,不需要调用这个方法(non_blocking()已经缓存了这个结果);只有在直接调用native_handle()这个方法的时候才需要用到这个方法。
  • at_mark()
    • 如果套接字要读的是一段OOB数据,这个方法返回true。这个方法很少会用到。
注意:socket实例不能拷贝

套接字实例不能被拷贝,因为拷贝构造方法和=操作符是不可访问的:

ip::tcp::socket s1(service), s2(service);
s1 = s2; // 编译时报错
ip::tcp::socket s3(s1); // 编译时报错

这是非常有意义的,因为每一个实例都拥有并管理着一个资源(原生套接字本身)。

如果允许拷贝构造,结果是会有两个实例拥有同样的原生套接字;这样就需要去处理所有者的问题(让一个实例拥有所有权?或者使用引用计数?还是其他的方法)。

Boost.Asio选择不允许拷贝(如果你想要创建一个备份,请使用共享指针)。

typedef boost::shared_ptr<ip::tcp::socket> socket_ptr;
socket_ptr sock1(new ip::tcp::socket(service));
socket_ptr sock2(sock1); // ok
socket_ptr sock3;            
sock3 = sock1; // ok

套接字缓冲区


数据缓冲区

当从一个套接字读写内容时,需要一个缓冲区,用来保存读取和写入的数据。

缓冲区内存的有效时间必须比I/O操作的时间要长,需要保证它们在I/O操作结束之前不被释放。否则操作未知内存,就要段错误啦。

对于同步操作来说,这很容易,这个缓冲区在receive和send时都存在。如下:

char buff[512];
//...
sock.receive(buffer(buff));
strcpy(buff, "ok\n");
sock.send(buffer(buff));

但是在异步操作时就没这么简单了,看下面的代码片段:

// 错误的代码
void on_read(const boost::system::error_code & err, std::size_t read_bytes)
{
     
		// 数据到来后开始读取到buff,但此时buff已经被释放,导致操作未知内存
}

void func() {
     
    char buff[512];
    sock.async_receive(buffer(buff), on_read); // 启动异步接收数据
		// 因为是异步操作,真正的数据接收操作发生在何时是未知的
} //此时buff的作用域结束,buff被释放

有几种方法可以解决这个问题:

  • 使用全局缓冲区(全局性与面向对象的封闭性对立,绝对避免两个实例使用同一个全局缓冲区)
  • 创建一个堆上缓冲区,然后在操作结束时释放它(使用智能指针可以避免内存泄漏)
  • 使用一个集合对象管理这些套接字和其他的数据(正确但复杂的解决方案,后面再介绍)
缓冲区封装函数

从之前的代码中可以看到,当需要对一个buffer进行读写操作时,代码会把实际的缓冲区对象封装在一个buffer()方法中,然后再把它传递给方法调用:

char buff[512];
sock.async_receive(buffer(buff), on_read);

因为Boost.Asio的接收缓冲区需要满足一定的需求,叫做ConstBufferSequence或者MutableBufferSequence。使用buffer()方法能达到目的。

以下类型都可以包装到buffer()方法中:

  • char[] const 数组
  • 字节大小的void *指针
  • std::string类型的字符串
  • POD const数组(POD代表纯数据,这意味着构造器和释放器不做任何操作)
  • pod数据的std::vector
  • 包含pod数据的boost::array
  • 包含pod数据的std::array

示例如下:

struct pod_sample {
      int i; long l; char c; };

char b1[512];
void * b2 = new char[512];
std::string b3; b3.resize(128);
pod_sample b4[16];
std::vector<pod_sample> b5; b5.resize(16);
boost::array<pod_sample,16> b6;
std::array<pod_sample,16> b7;

sock.async_send(buffer(b1), on_read);
sock.async_send(buffer(b2,512), on_read);
sock.async_send(buffer(b3), on_read);
sock.async_send(buffer(b4), on_read);
sock.async_send(buffer(b5), on_read);
sock.async_send(buffer(b6), on_read);
sock.async_send(buffer(b7), on_read);

函数


主要介绍connect/read/write函数。

  1. connect函数:把套接字连接到一个端点。
  • connect(socket, begin [, end] [, condition]):这个方法遍历队列中从begin到end的端点来尝试同步连接。
  • async_connect(socket, begin [, end] [, condition], handler):这个方法异步地调用连接方法,在结束时,它会调用完成处理方法。用法是void handler(constboost::system::error_code & err, Iterator iterator);。传递给处理方法的第二个参数是连接成功端点的迭代器(或者end迭代器)。
  1. read/write函数:读写流
  • async_read(stream, buffer [, completion] ,handler)

  • 异步地从一个流读取。结束时其处理方法被调用。

  • 处理方法的格式是:void handler(const boost::system::error_ code & err, size_t bytes);。

  • 可以选择指定一个完成处理方法。完成处理方法会在每个read操作调用成功之后调用,然后告诉Boost.Asio async_read操作是否完成(如果没有完成,它会继续读取)。

  • 格式是:size_t completion(const boost::system::error_code& err, size_t bytes_transfered) 。当这个完成处理方法返回0时,我们认为read操作完成;如果它返回一个非0值,它表示了下一个async_read_some操作需要从流中读取的字节数。

  • read(stream, buffer [, completion])

    • 同步地从一个流中读取数据。
  • async_write(stream, buffer [, completion], handler)

    • 异步地向一个流写入数据。
  • write(stream, buffer [, completion])

    • 同步地向一个流写入数据。

注意第一个参数变成了流,而不单是socket。这个参数包含了socket但不仅仅是socket。比如,你可以用一个Windows的文件句柄来替代socket。

当下面情况出现时,所有read和write操作都会结束:

  • 可用的缓冲区满了(当读取时)或者所有的缓冲区已经被写入(当写入时)
  • 完成处理方法返回0(如果提供了这么一个方法)
  • 错误发生时

下面的代码会异步地从一个socket中间读取数据直到读取到’\n’:

io_service service;
ip::tcp::socket sock(service);
char buff[512];
int offset = 0;
size_t up_to_enter(const boost::system::error_code &, size_t bytes) {
     
    for ( size_t i = 0; i < bytes; ++i)
        if ( buff[i + offset] == '\n') 
            return 0;
    return 1; 
 }
void on_read(const boost::system::error_code &, size_t) {
     }

async_read(sock, buffer(buff), up_to_enter, on_read);

Boost.Asio也提供了一些简单的完成处理仿函数:

  • transfer_at_least(n)
  • transfer_exactly(n)
  • transfer_all()

示例:

char buff[512]; 
void on_read(const boost::system::error_code &, size_t) {
     } 
// 读取32个字节 
async_read(sock, buffer(buff), transfer_exactly(32), on_read);

关于异步编程


同步编程比异步编程简单很多。这是因为,线性的思考是很简单的。

尽管异步编程更难,但是你会更倾向于选择使用它,比如,写一个需要处理很多并发访问的服务端时,并发访问越多,异步编程就比同步编程越简单。

在任何服务端(和任何基于网络的应用)都需要避免的,就是代码无响应的情况。同步编程意味着多线程,多线程意味着同步和锁,锁意味着性能消耗。在异步编程中就不用考虑这些问题了。

  1. 持续运行

如果有等待执行的操作,run()会一直执行,直到你手动调用io_service::stop()。

为了保证io_service一直执行,通常添加一个或者多个异步操作,然后在它们被执行时,继续一直不停地添加异步操作,比如下面代码:

using namespace boost::asio;
io_service service;
ip::tcp::socket sock(service);
char buff_read[1024], buff_write[1024] = "ok";
void on_read(const boost::system::error_code &err, std::size_t bytes);
void on_write(const boost::system::error_code &err, std::size_t bytes)
{
     
    sock.async_read_some(buffer(buff_read), on_read);
}
void on_read(const boost::system::error_code &err, std::size_t bytes)
{
     
    // ... 处理读取操作 ...
    sock.async_write_some(buffer(buff_write,3), on_write);
}
void on_connect(const boost::system::error_code &err) {
     
    sock.async_read_some(buffer(buff_read), on_read);
}
int main(int argc, char* argv[]) {
     
    ip::tcp::endpoint ep( ip::address::from_string("127.0.0.1"), 2001);
    sock.async_connect(ep, on_connect);
    service.run();
}
  1. 异步工作

异步工作不仅仅指用异步地方式接受客户端到服务端的连接、异步地从一个socket读取或者写入到socket。它包含了所有可以异步执行的操作。

通常是不知道每个异步handler的调用顺序的。除了通常的异步调用(来自异步socket的读取/写入/接收)。

可以使用io_service::strand会让异步方法被顺序调用(但这并不意味着它们都在同一个线程执行)。

三种添加异步操作的方法:

  • service.post(handler)
    • 这个方法能确保其在请求io_service实例,然后调用指定的处理方法之后立即返回。
    • handler稍后会在某个调用了service.run()的线程中被调用。
  • service.dispatch(handler)
    • 请求io_service实例去调用给定的处理方法
    • 但是另外一点,如果当前的线程调用了service.run(),它可以在方法中直接调用handler。
  • service.wrap(handler)
    • 创建了一个封装方法,当被调用时它会调用service.dispatch(handler)
  1. 保持活动

在使用某个变量时,它应该在有效作用域内,这一点不难理解。这在异步编程中尤其重要,因为稍不留神,就有可能犯下操作已经释放的内存的错误。

如下示例:

// sock和buff的存在时间都必须比read()操作本身时间要长,但是read操作持续的时间我们是不知道的,因为它是异步的。
io_service service;
ip::tcp::socket sock(service);
char buff[512];
void on_read(const boost::system::error_code &, size_t) {
     }

async_read(sock, buffer(buff), on_read);

当使用socket缓冲区的时候,你会有一个buffer实例在异步调用时一直存在(使用boost::shared_array<>)。

在这里,我们可以使用同样的方式,通过创建一个类并在其内部管理socket和它的读写缓冲区。

然后,对于所有的异步操作,传递一个包含智能指针的boost::bind仿函数给它:

using namespace boost::asio;
io_service service;
struct connection : boost::enable_shared_from_this<connection> {
     
    typedef boost::system::error_code error_code;
    typedef boost::shared_ptr<connection> ptr;
    connection() : sock_(service), started_(true) {
     }
    void start(ip::tcp::endpoint ep) {
     
        sock_.async_connect(ep, boost::bind(&connection::on_connect, shared_from_this(), _1));
    }
    void stop() {
     
        if ( !started_) return;
        started_ = false;
        sock_.close();
    }
    bool started() {
      return started_; }
private:
    void on_connect(const error_code & err) {
     
        // 这里你决定用这个连接做什么: 读取或者写入
        if ( !err) do_read();
        else stop();
    }
    void on_read(const error_code & err, size_t bytes) {
     
        if ( !started() ) return;
        std::string msg(read_buffer_, bytes);
        if ( msg == "can_login") do_write("access_data");
        else if ( msg.find("data ") == 0) process_data(msg);
        else if ( msg == "login_fail") stop();
    }
    void on_write(const error_code & err, size_t bytes) {
     
        do_read(); 
    }
    void do_read() {
     
        sock_.async_read_some(buffer(read_buffer_), boost::bind(&connection::on_read, shared_from_this(),   _1, _2)); 
    }
    void do_write(const std::string & msg) {
     
        if ( !started() ) return;
        // 注意: 因为在做另外一个async_read操作之前你想要发送多个消息, 
        // 所以你需要多个写入buffer
        std::copy(msg.begin(), msg.end(), write_buffer_);
        sock_.async_write_some(buffer(write_buffer_, msg.size()), boost::bind(&connection::on_write, shared_from_this(), _1, _2)); 
    }

    void process_data(const std::string & msg) {
     
        // 处理服务端来的内容,然后启动另外一个写入操作
    }
private:
    ip::tcp::socket sock_;
    enum {
      max_msg = 1024 };
    char read_buffer_[max_msg];
    char write_buffer_[max_msg];
    bool started_;
};

int main(int argc, char* argv[]) {
     
    ip::tcp::endpoint ep( ip::address::from_string("127.0.0.1"), 8001);
    connection::ptr(new connection)->start(ep);
}

说明:

  • 在所有异步调用中,我们传递一个boost::bind仿函数当作参数。这个仿函数内部包含了一个智能指针,指向connection实例。
  • 只要有一个异步操作等待时,Boost.Asio就会保存boost::bind仿函数的拷贝,这个拷贝保存了指向连接实例的一个智能指针,从而保证connection实例保持活动。
  • connection类仅仅是一个框架类,需要根据实际需求对它进行调整
  • 创建一个新的连接是相当简单的:connection::ptr(new connection)- >start(ep)。当需要关闭这个连接时,调用stop()。
  • 当实例被启动时(start()),它会等待客户端的连接。当连接发生时。on_connect()被调用。如果没有错误发生,它启动一个read操作(do_read())。
  • 当read操作结束时,就可以解析这个消息,处理数据。
  • 写回一个消息时,需要把它拷贝到缓冲区,然后像我在do_write()方法中所做的一样将其发送出去,因为这个缓冲区同样需要在这个异步写操作中一直存活。
  • 最后需要注意的一点————当写回时,需要指定写入的数量,否则,整个缓冲区都会被发送出去。

小结


Boost.Asio实现了端点的概念,可以认为是IP和端口。如果不知道准确的IP,可以使用resolver对象将主机名。

API的核心——socket类。Boost.Asio提供了TCP、UDP和 ICMP的实现。

异步编程是刚需。你应该已经明白为什么有时候需要用到它,尤其在写服务端的时候。

调用service.run()来实现异步循环就已经可以让你很满足,但是有时候你需要更进一步,尝试使用run_one()、poll()或者poll_one()。

实现异步时,你可以异步执行你自己的方法;使用service.post()或者service.dispatch()。

最后,为了使socket和缓冲区(read或者write)在整个异步操作的生命周期中一直活动,连接类需要继承自enabled_shared_from_this,然后在内部保存它需要的缓冲区,而且每次异步调用都要传递一个智能指针给this操作。

编程新手容易在最后一点上栽跟头。其实,即使了有经验的编程人员,在面对程序崩溃时gdb打印出的boost栈信息时,也不会兴奋。

本文理论偏多,读一遍有个印象,可以在编程尝试中遇到问题而不知如何解决时,再来回顾一下。

参考资料

Boost.Asio基本原理

你可能感兴趣的:(cpp,boost,asio,网络编程,理论基础)