本篇博客整理了五种 I/O 模型,其中重点展开了 I/O 多路转接和相关编程方式,旨在让读者深入理解网络编程下 I/O 的具体方式。
目录
一、高级 I/O
1)I/O 是什么
I/O,即输入/输出(input/output)的简称,那么,计算机中的输入/输出又是什么呢?
如今的计算机,如笔记本、台式机、服务器等,它们本质都是一堆硬件——cpu、内存、网卡、磁盘等的集合。但并不是说,把这些硬件随意放在一起就能够组成计算机,而是各硬件之间首先要具备协同能力,这就要求硬件与硬件之间要组织好,构建成一个系统,这样才能对外提供计算输出服务。数学家冯·诺依曼提出了计算机制造的三个基本原则,即采用二进制逻辑、程序存储执行以及计算机由五个部分组成(运算器、控制器、存储器、输入设备、输出设备),这套理论被称为冯•诺依曼体系结构。
如今,几乎所有的计算机,都遵循了冯•诺依曼体系结构。而在冯•诺依曼体系结构中,将数据从输入设备拷贝到内存的过程,就叫做输入;将数据从内存拷贝到输出设备的过程,就叫做输出。
I/O 也有类型之分,如文件 I/O、网络 I/O。其中,文件 I/O 即对文件进行的读写操作,所涉及的外设为磁盘,而网络 I/O 即对网络进行的读写操作,所涉及的外设为网卡。软硬件的灌流工作,是由操作系统负责的,因此 I/O 也基本是由操作系统来完成的。
I/O 主要分为两步,第一步是等,即等待 I/O 的条件就绪;第二步是拷贝,即在 I/O 的条件就绪后,将数据拷贝到内存或外设。
任何IO的过程,都包含“等”和“拷贝”这两个步骤,而实际上“等”所消耗的时间往往比“拷贝”所消耗的时间更多,因此,想要让IO变得高效,就必须尽可能减少“等”的时间。
【补】操作系统以中断的方式来得知外设上是否有数据就绪
输入是由操作系统来完成的,输入的过程就是操作系统将数据,从外设拷贝到内存的过程,而在此期间,操作系统一定要通过某种方法,得知特定外设上是否有数据就绪,然后再进行数据的拷贝。
外设中并不是时时刻刻都有数据可读,因此,操作系统做不到想要从外设读取数据时,就一定能读到数据。例如,用户通过一台主机正在访问某台服务器,当用户的请求报文发出后,发送请求的主机会进行等待,等待从自己的网卡中读取服务器发来的响应数据,但在此期间,对端服务器不是立刻就能将响应报文送达,它可能暂时还没有收到请求报文,或是正在对请求报文进行数据分析,或是响应报文还在网络中路由。
但操作系统的工作不仅仅有从外设读取数据,以确保网络通信通畅,因此,为了保证自己的工作效率,操作系统并不会主动去检测外设上是否有数据就绪,更何况大部分时候外设中是没有数据的。
于是,操作系统采用了中断的方式,来得知外设上是否有数据就绪。当某个外设上有数据就绪时,该外设就会向集成在 CPU 中的中断控制器发送中断信号,然后由中断控制器根据产生的中断信号的优先级,按顺序将其发送给CPU。
每一个中断信号都对应了一个中断处理程序,中断信号和中断处理程序之间的映射关系,被存储在中断向量表中。当 CPU 收到某个中断信号时,就会自动停止正在运行的程序,然后根据中断向量表的映射关系,找到并执行该中断信号所对应的中断处理程序,以处理该中断信号,等处理完毕后,CPU 再调度回刚刚被暂停的程序继续运行。
【补】操作系统对网卡数据的处理,与内核中维护的 sk_buff 结构息息相关。
任何时刻,操作系统都可能收到大量数据包,且需要将这些数据包管理起来。管理的具体方式是管理是“先描述,再组织”,即根据数据包来创建一个个结构体对象,并把这些结构体对象构建成一个数据结构,以此将对数据包的管理,转化为对数据结构的增删改查。
在内核中,有一个结构为 sk_buff,主要用于管理接收或发送的数据包的相关信息。
数据在被一台主机发送和接收的过程中,会贯穿网络协议栈。当一台主机接收到一个数据包后,也就是主机上的操作系统从网卡中读取到一个数据包后,会将其向上依次交给链路层、网络层、传输层、应用层,并在每一层进行相应的解包和分用。这使得 sk_buff 结构既必须是高效的,以保证主机处理网络报文的高效性,又必须能兼容所有的网络协议,以支持自己能被协议栈中的各个协议共同使用。
因此, sk_buff 结构在内核中被定义得十分复杂:
struct sk_buff { #ifdef __GENKSYMS__ /* These two members must be first. */ struct sk_buff *next; struct sk_buff *prev; ktime_t tstamp; #else union { struct { /* These two members must be first. */ struct sk_buff *next; struct sk_buff *prev; union { ktime_t tstamp; struct skb_mstamp skb_mstamp; __RH_KABI_CHECK_SIZE_ALIGN(ktime_t a, struct skb_mstamp b); }; }; struct rb_node rbnode; /* used in netem, ip4 defrag, and tcp stack */ }; #endif struct sock *sk; struct net_device *dev; /* * This is the control buffer. It is free to use for every * layer. Please put your private variables there. If you * want to keep them across layers you have to do a skb_clone() * first. This is owned by whoever has the skb queued ATM. */ char cb[48] __aligned(8); unsigned long _skb_refdst; #ifdef CONFIG_XFRM struct sec_path *sp; #endif unsigned int len, data_len; __u16 mac_len, hdr_len; union { __wsum csum; struct { __u16 csum_start; __u16 csum_offset; }; }; __u32 priority; kmemcheck_bitfield_begin(flags1); __u8 RH_KABI_RENAME(local_df, ignore_df) :1, cloned : 1, ip_summed : 2, nohdr : 1, nfctinfo : 3; __u8 pkt_type : 3, fclone : 2, ipvs_property : 1, peeked : 1, nf_trace : 1; kmemcheck_bitfield_end(flags1); __be16 protocol; void(*destructor)(struct sk_buff *skb); #if defined(CONFIG_NF_CONNTRACK) || defined(CONFIG_NF_CONNTRACK_MODULE) struct nf_conntrack *nfct; #endif #if IS_ENABLED(CONFIG_BRIDGE_NETFILTER) struct nf_bridge_info *nf_bridge; #endif /* fields enclosed in headers_start/headers_end are copied * using a single memcpy() in __copy_skb_header() */ /* private: */ RH_KABI_EXTEND(__u32 headers_start[0]) /* public: */ int skb_iif; RH_KABI_REPLACE(__u32 rxhash, __u32 hash) __be16 vlan_proto; __u16 vlan_tci; #ifdef CONFIG_NET_SCHED __u16 tc_index; /* traffic control index */ #ifdef CONFIG_NET_CLS_ACT __u16 tc_verd; /* traffic control verdict */ #endif #endif __u16 queue_mapping; kmemcheck_bitfield_begin(flags2); #ifdef CONFIG_IPV6_NDISC_NODETYPE __u8 ndisc_nodetype : 2; #endif __u8 pfmemalloc : 1; __u8 ooo_okay : 1; __u8 RH_KABI_RENAME(l4_rxhash, l4_hash) :1; __u8 wifi_acked_valid : 1; __u8 wifi_acked : 1; __u8 no_fcs : 1; __u8 head_frag : 1; /* Indicates the inner headers are valid in the skbuff. */ __u8 encapsulation : 1; RH_KABI_EXTEND(__u8 encap_hdr_csum : 1) RH_KABI_EXTEND(__u8 csum_valid : 1) RH_KABI_EXTEND(__u8 csum_complete_sw : 1) RH_KABI_EXTEND(__u8 xmit_more : 1) RH_KABI_EXTEND(__u8 inner_protocol_type : 1) RH_KABI_EXTEND(__u8 remcsum_offload : 1) /* 0/2 bit hole (depending on ndisc_nodetype presence) */ kmemcheck_bitfield_end(flags2); #if defined CONFIG_NET_DMA_RH_KABI || defined CONFIG_NET_RX_BUSY_POLL || defined CONFIG_XPS union { unsigned int napi_id; RH_KABI_EXTEND(unsigned int sender_cpu) RH_KABI_DEPRECATE(dma_cookie_t, dma_cookie) }; #endif #ifdef CONFIG_NETWORK_SECMARK __u32 secmark; #endif union { __u32 mark; __u32 dropcount; __u32 reserved_tailroom; }; #ifdef __GENKSYMS__ __be16 inner_protocol; #else union { __be16 inner_protocol; __u8 inner_ipproto; }; #endif __u16 inner_transport_header; __u16 inner_network_header; __u16 inner_mac_header; __u16 transport_header; __u16 network_header; __u16 mac_header; RH_KABI_EXTEND(kmemcheck_bitfield_begin(flags3)) RH_KABI_EXTEND(__u8 csum_level : 2) RH_KABI_EXTEND(__u8 rh_csum_pad : 1) RH_KABI_EXTEND(__u8 rh_csum_bad_unused : 1) /* one bit hole */ RH_KABI_EXTEND(__u8 offload_fwd_mark : 1) RH_KABI_EXTEND(__u8 sw_hash : 1) RH_KABI_EXTEND(__u8 csum_not_inet : 1) RH_KABI_EXTEND(__u8 dst_pending_confirm : 1) RH_KABI_EXTEND(__u8 offload_mr_fwd_mark : 1) /* 7 bit hole */ RH_KABI_EXTEND(kmemcheck_bitfield_end(flags3)) /* private: */ RH_KABI_EXTEND(__u32 headers_end[0]) /* public: */ /* RHEL SPECIFIC * * The following padding has been inserted before ABI freeze to * allow extending the structure while preserve ABI. Feel free * to replace reserved slots with required structure field * additions of your backport, eventually moving the replaced slot * before headers_end, if it need to be copied by __copy_skb_header() */ u32 rh_reserved1; u32 rh_reserved2; u32 rh_reserved3; u32 rh_reserved4; union { unsigned int napi_id; RH_KABI_EXTEND(unsigned int sender_cpu) RH_KABI_DEPRECATE(dma_cookie_t, dma_cookie) }; #endif #ifdef CONFIG_NETWORK_SECMARK __u32 secmark; #endif union { __u32 mark; __u32 dropcount; __u32 reserved_tailroom; }; #ifdef __GENKSYMS__ __be16 inner_protocol; #else kmemcheck_bitfield_begin(flags1); __u8 RH_KABI_RENAME(local_df, ignore_df) :1, cloned : 1, ip_summed : 2, nohdr : 1, nfctinfo : 3; __u8 pkt_type : 3, fclone : 2, ipvs_property : 1, peeked : 1, nf_trace : 1; kmemcheck_bitfield_end(flags1); __be16 protocol; void(*destructor)(struct sk_buff *skb); #if defined(CONFIG_NF_CONNTRACK) || defined(CONFIG_NF_CONNTRACK_MODULE) struct nf_conntrack *nfct; #endif #if IS_ENABLED(CONFIG_BRIDGE_NETFILTER) struct nf_bridge_info *nf_bridge; #endif /* fields enclosed in headers_start/headers_end are copied * using a single memcpy() in __copy_skb_header() */ /* private: */ /* private: */ RH_KABI_EXTEND(__u32 headers_start[0]) /* public: */ int skb_iif; RH_KABI_REPLACE(__u32 rxhash, __u32 hash) __be16 vlan_proto; __u16 vlan_tci; #ifdef CONFIG_NET_SCHED __u16 tc_index; /* traffic control index */ #ifdef CONFIG_NET_CLS_ACT __u16 tc_verd; /* traffic control verdict */ #endif #endif __u16 queue_mapping; kmemcheck_bitfield_begin(flags2); #ifdef CONFIG_IPV6_NDISC_NODETYPE __u8 ndisc_nodetype : 2; #endif __u8 pfmemalloc : 1; __u8 ooo_okay : 1; __u8 RH_KABI_RENAME(l4_rxhash, l4_hash) :1; __u8 wifi_acked_valid : 1; __u8 wifi_acked : 1; __u8 no_fcs : 1; __u8 head_frag : 1; /* Indicates the inner headers are valid in the skbuff. */ __u8 encapsulation : 1; RH_KABI_EXTEND(__u8 encap_hdr_csum : 1) RH_KABI_EXTEND(__u8 csum_valid : 1) RH_KABI_EXTEND(__u8 csum_valid : 1) RH_KABI_EXTEND(__u8 csum_complete_sw : 1) RH_KABI_EXTEND(__u8 xmit_more : 1) RH_KABI_EXTEND(__u8 inner_protocol_type : 1) RH_KABI_EXTEND(__u8 remcsum_offload : 1) /* 0/2 bit hole (depending on ndisc_nodetype presence) */ kmemcheck_bitfield_end(flags2); #if defined CONFIG_NET_DMA_RH_KABI || defined CONFIG_NET_RX_BUSY_POLL || defined CONFIG_XPS union { unsigned int napi_id; RH_KABI_EXTEND(unsigned int sender_cpu) RH_KABI_DEPRECATE(dma_cookie_t, dma_cookie) }; #endif #ifdef CONFIG_NETWORK_SECMARK __u32 secmark; #endif union { __u32 mark; __u32 dropcount; __u32 reserved_tailroom; }; #ifdef __GENKSYMS__ __be16 inner_protocol; #else union { __be16 inner_protocol; __u8 inner_ipproto; }; #endif __u16 inner_transport_header; __u16 inner_network_header; __u16 inner_mac_header; __u16 transport_header; __u16 network_header; __u16 mac_header; RH_KABI_EXTEND(kmemcheck_bitfield_begin(flags3)) RH_KABI_EXTEND(__u8 csum_level : 2) RH_KABI_EXTEND(__u8 rh_csum_pad : 1) RH_KABI_EXTEND(__u8 rh_csum_bad_unused : 1) /* one bit hole */ RH_KABI_EXTEND(__u8 offload_fwd_mark : 1) RH_KABI_EXTEND(__u8 sw_hash : 1) RH_KABI_EXTEND(__u8 csum_not_inet : 1) RH_KABI_EXTEND(__u8 dst_pending_confirm : 1) RH_KABI_EXTEND(__u8 offload_mr_fwd_mark : 1) /* 7 bit hole */ RH_KABI_EXTEND(kmemcheck_bitfield_end(flags3)) /* private: */ RH_KABI_EXTEND(__u32 headers_end[0]) /* public: */ /* RHEL SPECIFIC * * The following padding has been inserted before ABI freeze to * allow extending the structure while preserve ABI. Feel free * to replace reserved slots with required structure field * additions of your backport, eventually moving the replaced slot * before headers_end, if it need to be copied by __copy_skb_header() */ u32 rh_reserved1; u32 rh_reserved2; u32 rh_reserved3; u32 rh_reserved4; /* These elements must be at the end, see alloc_skb() for details. */ sk_buff_data_t tail; sk_buff_data_t end; unsigned char *head, *data; unsigned int truesize; atomic_t users; };
但如果仅针对数据包自底向上的解包和分用而言,sk_buff 结构就可以被大致简化为如下内容:
struct sk_buff{ char* transport_header; //传输层报头 char* network_header; //网络层报头 char* mac_header; //链路层报头 char* data; //被管理的数据包 struct sk buff* prev; //前驱指针 struct sk buff* next; //后继指针 }
每从网卡中读取一个数据包,操作系统就会为其定义一个 sk_buff 结构对象,然后将sk_buff 中的 data 指针,指向这个读取到的数据包,然后将其与其他 sk_buff 一起组织成一个双链表。
刚从网卡中读取的数据包,会向上交给最底层的链路层处理,在链路层进行相应的解包和分用。具体方式是,将 sk_buff 中的 mac_header 指针指向最初的数据包,并从数据包中分离链路层的报头,然后将 sk_buff 中的 network_header 指针,指向剩下的有效载荷,并将其继续向上交给网络层。
同理,在网络层完成相应的解包和分用后,sk_buff 中的 transport_header 指针,将指向数据包中网络层报头之后的有效载荷,然后继续被向上交给传输层,并在传输层中也经历类似的过程。
在传输层完成相应的解包和分用后,剩下的数据会被拷贝到传输层协议的接收缓冲区中,以供用户在应用层进行读取。
2)五种 I/O 模型
.1- 钓鱼五人组的故事
任何 I/O 都包含“等”和“拷贝”这两个步骤。等,即等待 I/O 的条件就绪;拷贝,即在 I/O 的条件就绪后,将数据拷贝到内存或外设。
其实,钓鱼的过程与 I/O 的过程非常相似。在钓鱼时,也需要“等”鱼上钩,即要“等”鱼的条件就绪;且也需要“拷贝”,即在鱼的条件就绪后,将鱼“拷贝”到鱼桶中。
假设有五个人——张三、李四、王五、赵六、田七——正在钓鱼,且每个人都有自己的钓鱼方式:
- 张三:拿了 1 个鱼竿,将鱼钩抛入水中后就死死的盯着浮漂,什么也不做,当有鱼上钩后就挥动鱼竿将鱼钓上来。
- 李四:拿了 1 个鱼竿,将鱼钩抛入水中后就去做其他事情,然后定期观察浮漂,如果有鱼上钩则挥动鱼竿将鱼钓上来,否则继续去做其他事情。
- 王五:拿了 1 个鱼竿,将鱼钩抛入水中后在鱼竿顶部绑一个铃铛,然后就去做其他事情,如果铃铛响了就挥动鱼竿将鱼钓上来,否则就根本不管鱼竿。
- 赵六:拿了 100 个鱼竿,将 100 个鱼竿抛入水中后就定期观察这 100 个鱼竿的浮漂,如果某个鱼竿有鱼上钩则挥动对应的鱼竿将鱼钓上来。
- 田七:田七是一个有钱的老板,他给了自己的司机一个桶、一个电话、一个鱼竿,让司机去钓鱼,当鱼桶装满的时候再打电话告诉田七来拿鱼,而田七自己则开车去做其他事情去了。
其中,张三、李四、王五的钓鱼效率本质上是一样的。首先,他们的钓鱼方式是一样的,都是先等鱼上钩,然后再将鱼钓上来;其次,他们每个人都是拿的一根鱼竿,当河里有鱼来咬鱼钩时,这条鱼咬哪一个鱼钩的概率都是相等的。
张三、李四、王五、赵六都是自己亲自钓鱼,但很明显,赵六是这四人中钓鱼效率最高的。赵六持有多个鱼竿,可以同时等待多个鱼竿有鱼上钩,因此在单位时间内,赵六的鱼竿有鱼上钩的概率在四人中是最大的。假设赵六拿了 97 个鱼竿,加上张三、李四、王五一人一个的鱼竿一共就有 100 个鱼竿。每当有一条鱼来咬钩时,咬张三、李四、王五鱼钩的概率都是百分之一,但咬赵六的鱼钩的概率是百分之九十七。
田七本人并没有参与整个钓鱼的过程,但他仍能获得鱼。他发起了钓鱼的任务,让自己的司机帮自己钓鱼,在此期间自己可以做其他任何事情。
而从钓鱼的过程话说回 I/O 的过程,鱼所在的河对应就是内核,每一个参与钓鱼的人对应就是进程或线程,鱼竿对应就是文件描述符或套接字,鱼桶对应就是用户缓冲区。
这五人不同的钓鱼方式,分别就对应了五种不同的 I/O 模型:
- 张三这种死等的钓鱼方式,就类似于阻塞 I/O。
- 李四这种定时检测是否有鱼上钩的方式,就类似于非阻塞 I/O。
- 王五这种通过设置铃铛得知事件是否就绪的方式,就类似于信号驱动 I/O。
- 王五这种一次等待多个鱼竿上有鱼的钓鱼方式,就类似于 I/O 多路转接。
- 田七这种让别人帮自己钓鱼的钓鱼方式,就类似于异步 I/O。
根据当前进程或线程是否需要参与 I/O 过程,I/O 可以被分为同步 I/O 和异步 I/O。其中,阻塞 I/O、非阻塞 I/O、信号驱动 I/O、I/O 多路转接是属于同步 I/O。
.2- 阻塞 I/O
阻塞 I/O 是最常见的 I/O 模型,其特点是,在内核将数据准备好之前,相关系统调用会一直阻塞等待。
进程或线程在进行 I/O 操作时,如果相关系统调用在“等”和“拷贝”期间都不会返回,那么在用户看来,进程或线程就像是阻塞住了。于是,这种 I/O 就被称为阻塞 I/O。
所有的套接字,其实默认都是阻塞 I/O。例如当调用 recvfrom() 从某个套接字上读取数据时,可能底层数据还没有准备好,于是就需要等待数据就绪,直到数据就绪后,再将数据从内核拷贝到用户空间,而此时 recvfrom() 才会返回。在 recvfrom() 等待数据就绪期间,在用户看来,该进程或线程就阻塞住了,其本质是操作系统将该进程或线程的状态设置为某种非 R 状态,并将其放入等待队列中,直到数据就绪后,操作系统才将其从等待队列中唤醒,然后该进程或线程才会将数据从内核拷贝到用户空间。
.3- 非阻塞 I/O
非阻塞 I/O 往往需要程序员以循环的方式,反复尝试读写文件描述符,这个过程被称为轮询,对 CPU 来说是较大的浪费,因此非阻塞 I/O 一般只在特定场景下使用。
其特点是,如果内核还未将数据准备好,相关系统调用仍然会直接返回,且同时返回 EWOULDBLOCK 错误码。
进程或线程在进行 I/O 操作时,哪怕数据没有就绪,相关系统调用始终会立即返回,那么在用户看来,进程或线程就从未被阻塞住。于是,这种 I/O 就被称为非阻塞 I/O。
非阻塞 I/O 所涉及的特定场景,例如,当调用 recvfrom() 从某个套接字上读取数据时,哪怕底层数据还没有准备好,recvfrom() 都会立即错误返回,而不会让该进程或线程进行阻塞等待。由于没有当前要读取的数据,于是该进程或线程后续还需要继续调用 recvfrom() 以检测底层数据是否就绪,若一直没有就绪,则每次调用 recvfrom() 都会错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间,并进行成功返回。
.4- 信号驱动 I/O
信号驱动 I/O 的特点是,当内核将数据准备好时,操作系统会用 SIGIO 信号通知相关应用程序进行 I/O 操作。
当底层数据就绪时,操作系统会向当前进程或线程递交 SIGIO 信号,由此,可以通过系统调用 signal() 或 sigaction() 将 SIGIO 的信号处理动作,自定义为需要进行的 I/O 操作,使得底层数据就绪时,相应的 I/O 操作能够自动执行。
例如,调用 recvfrom() 从某个套接字上读取数据,那么就可以将该操作定义为SIGIO的信号处理程序。当底层数据就绪时,操作系统就会递交 SIGIO 信号,并自动执行自定义的信号处理程序,使数据可以从内核拷贝到用户空间。
尽管信号在任何时刻都可能产生,也因此信号的产生是异步的,但相关进程或线程仍会参与 I/O 的过程,因此信号驱动 I/O 是属于同步 I/O,与“信号的产生是异步的”无关。
.5- I/O 多路转接
I/O 多路转接也叫做 I/O 多路复用,虽然与阻塞 I/O 类似,但其核心特点在于,能够同时等待多个文件描述符的就绪状态。
因 I/O 多路转接,系统特别提供了一些接口,来帮多个进程或线程进行排队,使排队时间重叠,提高效率。
I/O 的过程分为“等”和“拷贝”两步,因此相关系统调用在底层都做了两件事,一件就是在数据不就绪时让进程或线程进行等待,另一件就是在数据就绪后进行数据的拷贝。
虽然 recvfrom() 等接口也具有有“等”的能力,但这些接口一次只能“等”一个文件描述符上的数据或空间就绪,如此,I/O 效率就太低了。
于是,系统又提供了三组多路转接接口,分别为 select()、poll()、epol(),支持一次性“等”多个文件描述符,以此将进程或线程“等”的时间进行重叠,便于数据就绪后调用对应的 recvfrom() 等接口进行数据的拷贝。
.6- 异步 I/O
异步I/O 的特点是,有操作系统来完成数据的拷贝,并在拷贝完成时,通知相关应用程序。
进行异步 I/O 的进程或线程,并不参与 I/O 的过程,因此异步 I/O 也不涉及“等”和“拷贝”,而只负责只是发起 I/O,并让操作系统去负责“等”和“拷贝”。
系统也特别为异步 I/O 提供了一些接口,这些接口在调用后会发起 I/O,然后立即返回,始终不参与“等”和“拷贝”。
3)同步通信与异步通信
消息通信机制有同步和异步之分。
同步是指,调用者主动等待这个调用的结果。在发起一个系统调用时,若尚未得到结果,该系统调用就不返回,一旦返回,即可得到结果。
异步则相反,在发起一个系统调用时,该调用会立刻返回,虽然不会直接返回结果,但会通过状态、通知等来告知调用者,或通过回调函数来返回结果。
【ps】同步通信、进程/线程同步,并不相干
在多进程和多线程中,的确有同步的概念。进程或线程同步指的是,进程或线程间的一种工作关系,在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
但同步通信和进程或线程之间的同步是完全不相干的概念。同步通信指的是,进程或线程与操作系统之间的关系、进程或线程是否需要主动参与 I/O 过程。
看到“同步”这个词,首先得明确,是同步通信的同步,还是进程/线程同步的同步。
4)阻塞 I/O 与非阻塞 I/O
程序在等待调用结果(消息、返回值)时的状态,有阻塞和非阻塞之分。阻塞调用是指,在调用结果返回之前,当前程序会被挂起,只有在得到结果后才被唤醒;非阻塞调用则不会阻塞当前程序,无论有没有得到结果。
系统中的大部分接口都是阻塞式接口,例如可以从标准输入(键盘)中读取数据 read()。
为了更好地演示 read() 的功能,此处引入以下代码:
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
int main()
{
char buffer[1024]; //存储从键盘输入的字符
//每次在键盘输入后,打印输入的内容
while (1){
ssize_t size = read(0, buffer, sizeof(buffer)-1); //将键盘中的数据存入 buffer
if (size < 0){
std::cerr << "read error" << std::endl;
break;
}
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl; //打印读取的数据
}
return 0;
}
由演示图,在尚未从键盘输入任何内容并按下回车之前,程序并未向下执行,而在键盘输入“1”并按下回车后,程序就立即执行了打印操作。
这说明 read() 本身是一种阻塞式接口。底层数据不就绪, read() 就进行阻塞等待,直到底层数据就绪时,才将数据拷贝到 buffer 数组并打印。
非阻塞式接口虽然少见,但功能同样强大。
一般来说,打开文件默认是以阻塞方式的。如果要以非阻塞方式打开文件,需要在调用 open() 时携带 O_NONBLOCK 或 O_NDELAY 选项。
如果要将已打开的文件或套接字设置为非阻塞,就需要用到 fcntl()。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);
功能:将文件描述符/套接字设置为非阻塞
参数:1.fd:已打开的文件描述符。
2.cmd:需要进行的操作,一般有如下选项:
复制一个现有的描述符,cmd = F_DUPFD。
获得/设置文件描述符标记,cmd = F_GETFD 或 F_SETFD。
获得/设置文件状态标记,cmd = F_GETFL 或 F_SETFL。
获得/设置异步I/O所有权,cmd = F_GETOWN 或 F_SETOWN。
获得/设置记录锁,cmd = F_GETLK、F_SETLK 或 F_SETLKW。
3.“...”:可变参数,传入的 cmd 值不同,后面追加的参数也不同。
返回值:成功则返回值取决于具体进行的操作;失败则返回-1,并设置合适的错误码。
为了更好地演示 fcntl() 的功能,此处引入以下代码:
//将 read() 改为以非阻塞轮询的方式从标准输入(键盘)读取数据
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <cstring>
#include <cerrno>
bool SetNonBlock(int fd) //将已打开的文件设置为非阻塞
{
//获取相应文件的状态
//传入相应的文件描述符、文件状态标记(本身是一个位图)
int fl = fcntl(fd, F_GETFL);
if (fl < 0){
std::cerr << "fcntl error" << std::endl;
return false;
}
//设置文件的状态为非阻塞
//传入的 cmd 值为 F_SETFL,表示要设置文件的状态
//通过“fl | O_NONBLOCK”在获取到的文件状态标记上添加非阻塞标记
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
return true;
}
int main()
{
SetNonBlock(0); //将标准输入流设置为非阻塞
char buffer[1024]; //存储从键盘获取的数据
//不停调用 read() 进行轮询,以检测数据是否就绪,
//数据就绪后,read() 会从键盘获取数据并打印
while (1){
//调用 read() 读取数据
ssize_t size = read(0, buffer, sizeof(buffer)-1);
//1.读取未完成
if (size < 0){
//底层数据没有就绪,
//read() 会立即以出错的形式返回,错误码会被设置为 EAGAIN 或 EWOULDBLOCK。
if (errno == EAGAIN || errno == EWOULDBLOCK){
std::cout << strerror(errno) << std::endl;//打印错误信息
sleep(1);
continue;
}
//在读取数据之前被信号中断
else if (errno == EINTR){
std::cout << strerror(errno) << std::endl;//打印错误信息
sleep(1);
continue;
}
else{
std::cerr << "read error" << std::endl;
break;
}
}
//2.读取完成
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl; //打印从键盘获取的数据
}
return 0;
}
由演示图,在尚未从键盘输入任何内容并按下回车之前,即数据还未就绪,程序会一直打印错误信息,直到在键盘输入“1”并按下回车后,程序不再打印错误信息,而立即打印了输入的“1”。
二、I/O 多路转接的相关接口
I/O 多路转接也叫做 I/O 多路复用,虽然与阻塞 I/O 类似,但其核心特点在于,能够同时等待多个文件描述符的就绪状态。
因 I/O 多路转接,系统特别提供了一些接口,来帮多个进程或线程进行排队,使排队时间重叠,提高效率。
I/O 的过程分为“等”和“拷贝”两步,因此相关系统调用在底层都做了两件事,一件就是在数据不就绪时让进程或线程进行等待,另一件就是在数据就绪后进行数据的拷贝。
虽然 recvfrom() 等接口也具有有“等”的能力,但这些接口一次只能“等”一个文件描述符上的数据或空间就绪,如此,I/O 效率就太低了。
于是,系统又提供了三组多路转接接口,分别为 select()、poll()、epol(),支持一次性“等”多个文件描述符,以此将进程或线程“等”的时间进行重叠,便于数据就绪后调用对应的 recvfrom() 等接口进行数据的拷贝。
1)select()
.1- 接口的基本信息
select() 的核心工作就是等,当监视的多个文件描述符中有一个或多个事件就绪时,select() 才会成功返回,并将对应文件描述符的就绪事件告知用户。
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
功能:同时监视多个文件描述符的上的事件是否就绪
参数:1.nfds:在需要监视的文件描述符中,最大的文件描述符值+1。
2.readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,
返回时内核告知用户哪些文件描述符的读事件已经就绪。
3.writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,
返回时内核告知用户哪些文件描述符的写事件已经就绪。
4.exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,
返回时内核告知用户哪些文件描述符的异常事件已经就绪。
5.timeout:输入输出型参数,调用时由用户设置select()的等待时间,返回时表示timeout的剩余时间。
该参数的取值一般包括:
1)nullptr:select()调用后进行阻塞等待,
直到被监视的某个文件描述符上的某个事件就绪。
2)0:select()调用后进行非阻塞等待,
无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
3)特定的时间值:select()调用后在指定的时间内进行阻塞等待,
如果被监视的文件描述符上一直没有事件就绪,则在该时间后select进行超时返回。
返回值:调用成功,则返回事件就绪的文件描述符个数;
如果 timeout 时间耗尽,则返回 0;
调用失败,则返回 -1,并设置合适的错误码。错误码可能被设置为:
· EBADF:文件描述符为无效的或该文件已关闭。
· EINTR:此调用被信号所中断。
· EINVAL:参数nfds为负值。
· ENOMEM:核心内存不足。
【补】socket 就绪条件
1)读就绪,情况如下:
- socket 内核中,接收缓冲区中的字节数,大于等于低水位标记 SO_RCVLOWAT,此时可以无阻塞的读取该文件描述符,并且返回值大于 0。
- socket TCP 通信中,对端关闭连接,此时对该 socket 读,则返回 0。
- 监听的 socket 上有新的连接请求。
- socket 上有未处理的错误。
2)写就绪,情况如下:
- socket 内核中,发送缓冲区中的可用字节数,大于等于低水位标记 SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于 0。
- socket 的写操作被关闭(close或shutdown),对一个写操作被关闭的 socket 进行写操作,会触发 SIGPIPE 信号。
- socket 使用非阻塞 connect 连接成功或失败之后。
- socket 上有未读取的错误。
3)异常就绪,情况如下:
- socket 上收到带外数据(ps:带外数据和 TCP 的紧急模式相关,将 TCP 报头中的 URG 标志位和16位紧急指针搭配使用,就能够发送/接收带外数据)。
【补】fd_set 结构
fd_set 是文件描述符集,与 sigset_t 类似,本质也是一个位图,用位图中对应的位,来表示要监视的文件描述符,其在内核中定义如下:
//内核中的fd_set #define __NFDBITS (8 * sizeof(unsigned long)) #define __FD_SETSIZE 1024 #define __FDSET_LONGS (__FD_SETSIZE/__NFDBITS) // 1024 / (8 * 4 or 8) => 2^10^ / 2^5^ or 2^6^ => 32 or 16 typedef struct { unsigned long fds_bits [__FDSET_LONGS]; //32 * 4 or 16 * 8,但总大小都是128*8 = 1024比特位,即最大能存放这么多个文件描述符。 } __kernel_fd_set;
调用 select() 之前,还需要先用 fd_set 结构定义出对应的文件描述符集,然后通过位操作将要监视的文件描述符添加到 fd_set 中。
不过,位操作无需用户自己进行,系统提供了一组专门的接口,用于对 fd_set 类型的位图进行各种操作:
//位图操作接口: void FD_CLR(int fd, fd_set *set); //从位图中移除文件描述符。 int FD_ISSET(int fd, fd_set *set);//查看文件描述符在位图中是否存在。 void FD_SET(int fd, fd_set *set); //在位图中设置文件描述符。 void FD_ZERO(fd_set *set); //清空或初始化位图。
【补】timeval 结构
参数 timeout,是一个指向 timeval 结构的指针。
timeval 结构用于描述一段时间长度,该结构中包含两个成员,其中,tv_sec 表示的是秒,tv_usec 表示的是微秒。
struct timeval { time_t tv_sec; //seconds,秒 suseconds_t tv_usec; //microseconds,毫秒 };
.2- select 服务端
select 服务器的基本功能是,不断调用 select() 等待事件就绪,当事件就绪时,执行相应的某种动作。而这里的事件,就是有客户端发来数据、可以读取数据了(读事件),这里的执行动作,就是读取和打印客户端发来的数据。
【Tips】select 服务器的设计思路
- 初始化服务器,完成套接字的创建、绑定和监听。
- 定义一个 fd_array 数组,用于保存监听套接字、accept() 返回的套接字,并在一开始就将监听套接字添加到 fd_array 数组中。
- 接下来,让服务器程序循环调用 select(),检测读事件是否就绪,如果就绪则执行对应的操作。每次调用 select() 之前,都需要定义一个读文件描述符集 readfds,并将 fd_array 中的文件描述符,依次设置进 readfds 中,好让 select() 监视这些文件描述符的读事件是否就绪。
- 当 select() 检测到数据就绪时,会将读事件就绪的文件描述符设置进 readfds 中,由此就可以得知哪些文件描述符的读事件就绪了,并对这些文件描述符进行对应的操作。
- 如果读事件就绪的是监听套接字,则调用 accept() 从底层的全连接队列,获取已经建立好的连接,并将该连接对应的套接字添加到 fd_array 数组中。
- 如果读事件就绪的是 accept() 返回的套接字,则调用 read() 读取客户端发来的数据并进行打印输出。不过,accept() 返回的套接字的读事件就绪,也可能是因为客户端将连接关闭了,那么该套接字就无须继续监视了,此时服务器既需要调用 close() 来关闭该套接字,又需要将该套接字从fd_array 数组中清除。
注:欲知 socket 编程的完整原理和细节,请移步至:【Linux网络】套接字编程
首先单独编写一个 Socket 类,对套接字相关的接口进行封装:
- Socket.hpp
//ps:为了让外部能直接调用 Socket 类的成员函数,此处将它们定义成为静态的
#pragma once
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdlib>
class Socket{
public:
//创建套接字
static int SocketCreate()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//设置端口复用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
//绑定
static void SocketBind(int sock, int port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
socklen_t len = sizeof(local);
if (bind(sock, (struct sockaddr*)&local, len) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
}
//监听
static void SocketListen(int sock, int backlog)
{
if (listen(sock, backlog) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
};
然后单独编写一个 SelectServer 类,作为select 服务器的主体:
- SelectServer.hpp
#pragma once
#include "Socket.hpp"
#include <sys/select.h>
#define BACK_LOG 5 //全连接队列的默认大小
#define NUM 1024 //fd_array 的大小
#define DFL_FD - 1 //fd_array 中所有元素的初始值
class SelectServer{
public:
//构造
SelectServer(int port)
: _port(port)
{}
//初始化套接字
void InitSelectServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
}
//在析构中关闭套接字
~SelectServer()
{
if (_listen_sock >= 0){
close(_listen_sock);
}
}
//检测事件是否就绪
void Run()
{
fd_set readfds; //定义读文件描述符集
int fd_array[NUM]; //定义保存待监视文件描述符的数组
//将数组中的所有位置设置为无效
ClearFdArray(fd_array, NUM, DFL_FD);
//将监听套接字添加到 fd_array 数组中下标为0的位置
fd_array[0] = _listen_sock;
//循环调用select()检测事件是否就绪
while(1){
//先清空 readfds
FD_ZERO(&readfds);
//再将fd_array中的文件描述符填入到 readfds 中,并记录最大的文件描述符
int maxfd = DFL_FD;
for (int i = 0; i < NUM; i++){
if (fd_array[i] == DFL_FD) //跳过数组中的无效元素
continue;
FD_SET(fd_array[i], &readfds); //将有效元素添加到 readfds
if (fd_array[i] > maxfd) //每次遍历,更新最大文件描述符
maxfd = fd_array[i];
}
//正式调用 select(),并根据其返回值来决定后续的处理动作
switch (select(maxfd + 1, &readfds, nullptr, nullptr, nullptr)){
case 0: // timeout 时间耗尽,则返回0
std::cout<<"timeout..."<<std::endl;
break;
case -1: //调用失败,则返回-1
std::cerr << "select error" << std::endl;
break;
default: //调用成功,则正常处理事件
std::cout<<"something happening..."<<std::endl;
HandlerEvent(readfds, fd_array, NUM);
break;
}
}
}
//对就绪事件进行处理
void HandlerEvent(const fd_set& readfds, int fd_array[], int num)
{
for (int i = 0; i < num; i++){
if (fd_array[i] == DFL_FD){ //跳过数组的无效元素
continue;
}
//连接事件就绪(当前元素为监听套接字 && 监听套接字在readfds存在)
if (fd_array[i] == _listen_sock && FD_ISSET(fd_array[i], &readfds)){
//获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
//将获取到的套接字添加到 fd_array 中
if (!SetFdArray(fd_array, num, sock)){
close(sock); //添加失败则关闭该套接字
std::cout << "select server is full, close fd: " << sock << std::endl;
}
}
//读事件就绪(当前元素不为监听套接字 && 该套接字在readfds存在)
else if (FD_ISSET(fd_array[i], &readfds)){
char buffer[1024];
//调用 read() 读取数据
ssize_t size = read(fd_array[i], buffer, sizeof(buffer)-1);
if (size > 0){ //读取成功则打印数据
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
else if (size == 0){ //对端连接关闭,则将该套接字关闭并从fd_array中清除
std::cout << "client quit" << std::endl;
close(fd_array[i]);
fd_array[i] = DFL_FD;
}
else{ //读取失败,也要将该套接字关闭并从fd_array中清除
std::cerr << "read error" << std::endl;
close(fd_array[i]);
fd_array[i] = DFL_FD;
}
}
}
}
private:
//将 fd_array 中所有元素的值初始化为 default_fd
void ClearFdArray(int fd_array[], int num, int default_fd)
{
for (int i = 0; i < num; i++){
fd_array[i] = default_fd;
}
}
//将就绪的套接字加入 fd_array
bool SetFdArray(int fd_array[], int num, int fd)
{
for (int i = 0; i <num; i++){
if (fd_array[i] == DFL_FD){ //说明该位置没有被占用
fd_array[i] = fd;
return true; //说明添加成功
}
}
return false; //说明 fd_array 数组已满
}
private:
int _listen_sock; //监听套接字
int _port; //端口号
//... //IP地址
//由于小编代码的运行环境为云服务器,因此无须显示绑定IP地址,也就无须IP地址
//将IP地址设置为INADDR_ANY即可
};
最后,编写程序主函数:
- Main.cc
#include "SelectServer.hpp"
#include <string>
static void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2){
Usage(argv[0]);
exit(1);
}
int port = atoi(argv[1]);
SelectServer* svr = new SelectServer(port);
svr->InitSelectServer();
svr->Run();
return 0;
}
- Makefile
SelectServer:Main.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f SelectServer
编译后的程序名 + 端口号即可运行 select 服务器。
用 telnet 指令 + 127.0.0.1 + 端口号充当客户端程序,进行本机环回测试。
开启两个窗口进行本机环回测试,连接服务器。
两个窗口分别向服务器发送信息,服务器均能接收并打印。
本机环回的窗口退出后,服务器会正常关闭对应的连接,并将对应的套接字从 fd_array 中清除。
尽管此时的 select 服务器已经可以正常运行,并满足编写之前预期的功能,但仍存在一些问题,例如服务器没有对客户端发进行响应、没有定制协议、没有对应的输入输出缓冲区等。
.3- 优缺点
【Tips】select() 的优点
- 可以同时等待多个文件描述符,并且只负责等待,实际的 I/O 操作由accept()、read()、write()等接口来完成,这些接口在进行 I/O 操作时不会被阻塞。
- 可以将“等”的时间重叠,提高 I/O 的效率。
【Tips】select() 的缺点
- 每次调用select(),都需要手动设置套接字集合,从接口使用角度来说非常不便。
- 每次调用select(),都需要把套接字集合从用户态拷贝到内核态,在套接字很多时,相应的开销会很大。
- 每次调用select(),都需要在内核遍历传递进来的所有套接字,在套接字很多时,相应的开销会很大。
- select() 可监控的文件描述符数量太少,仅 1024 个,而一个进程能打开的文件描述符个数远大于 1024,这使得 select 服务器处理连接的能力很有限。
【补】多路转接接口的适用场景
select()、poll()、epoll() 虽然功能强大,但需要在合适的场景下使用,否则可能会适得其反。
这些接口一般适用于多连接、且多连接中只有少部分连接比较活跃的场景。
少量连接比较活跃,也就意味着几乎所有的连接在进行 I/O 操作时,都需要花费大量时间来等待事件就绪,使得这些多路转接接口可以将其进行重叠,以提高 I/O 效率。
但对于多连接中大部分连接都很活跃的场景,这些接口就有些勉强了。每个连接都很活跃,也就意味着任何时刻每个连接上的事件基本都是就绪的,此时其实无需动用多路转接接口来帮助等待,毕竟多路转接接口也需要花费系统的时间资源和空间资源。
2)poll()
.1- 接口的基本信息
poll() 的定位和 select() 大致相同,也可以同时监视多个文件描述符上的事件是否就绪。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能:同时监视多个文件描述符的上的事件是否就绪
参数:1.fds:一个poll()监视的结构列表,每一个元素包含三部分内容:
文件描述符、监视的事件集合、就绪的事件集合。
2.nfds:表示fds数组的长度。
3.timeout:表示poll()的超时时间,单位是毫秒(ms)。
该参数的取值一般包括:
1)nullptr:select()调用后进行阻塞等待,
直到被监视的某个文件描述符上的某个事件就绪。
2)0:select()调用后进行非阻塞等待,
无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
3)特定的时间值:select()调用后在指定的时间内进行阻塞等待,
如果被监视的文件描述符上一直没有事件就绪,则在该时间后select进行超时返回。
返回值:调用成功,则返回事件就绪的文件描述符个数;
如果 timeout 时间耗尽,则返回 0;
调用失败,则返回 -1,并设置合适的错误码。错误码可能被设置为:
· EBADF:文件描述符为无效的或该文件已关闭。
· EINTR:此调用被信号所中断。
· EINVAL:参数nfds为负值。
· ENOMEM:核心内存不足。
【补】pollfd 结构
pollfd 结构的定义如下:
struct pollfd { int fd; /* 文件描述符 */ short events; /* 所关心的事件 */ short revents; /* 返回时已经就绪的事件 */ };
其中,events 和 revents的取值如下:
这些取值都是以宏的方式定义的,它们的二进制序列中有且仅有一个比特位为 1,且它们之间为 1 的比特位各不相同。
在调用 poll() 之前,可以通过“|”或运算,将要监视的事件添加到 events 中;在 poll() 返回后,可以通过“&”运算。检测 revents 中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪。
.2- poll 服务端
此处实现的 poll 服务器与上文的 select 服务器功能类似,也是不断调用 poll() 等待事件就绪,当事件就绪时,执行相应的某种动作。而这里的事件,就是有客户端发来数据、可以读取数据了(读事件),这里的执行动作,就是读取和打印客户端发来的数据。
首先单独编写一个 Socket 类,对套接字相关的接口进行封装:
- Socket.hpp
//ps:为了让外部能直接调用 Socket 类的成员函数,此处将它们定义成为静态的
#pragma once
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdlib>
class Socket{
public:
//创建套接字
static int SocketCreate()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//设置端口复用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
//绑定
static void SocketBind(int sock, int port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
socklen_t len = sizeof(local);
if (bind(sock, (struct sockaddr*)&local, len) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
}
//监听
static void SocketListen(int sock, int backlog)
{
if (listen(sock, backlog) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
};
然后单独编写一个 PollServer 类,作为 poll 服务器的主体:
- PollServer.hpp
#pragma once
#include "Socket.hpp"
#include <poll.h>
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD - 1
class PollServer{
public:
PollServer(int port)
: _port(port)
{}
void InitPollServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
}
~PollServer()
{
if (_listen_sock >= 0){
close(_listen_sock);
}
}
//检测事件是否就绪
void Run()
{
struct pollfd fds[NUM];
//初始化fds数组
ClearPollfds(fds, NUM, DFL_FD);
//将监听套接字添加到fds数组中
SetPollfds(fds, NUM, _listen_sock);
//循环调用 poll()
while(1){
switch (poll(fds, NUM, -1)){
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
std::cerr << "poll error" << std::endl;
break;
default:
//正常的事件处理
std::cout<<"something happening..."<<std::endl;
HandlerEvent(fds, NUM);
break;
}
}
}
//处理就绪事件
void HandlerEvent(struct pollfd fds[], int num)
{
for (int i = 0; i < num; i++){
if (fds[i].fd == DFL_FD){ //跳过无效元素
continue;
}
//连接事件就绪
if (fds[i].fd == _listen_sock&&fds[i].revents&POLLIN){
//获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
//将获取到的套接字添加到fds数组中
if (!SetPollfds(fds, NUM, sock)){
close(sock);
std::cout << "poll server is full, close fd: " << sock << std::endl;
}
}
//读事件就绪
else if (fds[i].revents&POLLIN){
char buffer[1024];
ssize_t size = read(fds[i].fd, buffer, sizeof(buffer)-1);
//读取成功
if (size > 0){
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
//对端连接关闭
else if (size == 0){
std::cout << "client quit" << std::endl;
close(fds[i].fd);
UnSetPollfds(fds, i); //将该文件描述符从fds数组中清除
}
//读取失败
else{
std::cerr << "read error" << std::endl;
close(fds[i].fd);
UnSetPollfds(fds, i);
}
}
}
}
private:
//初始化fds数组
void ClearPollfds(struct pollfd fds[], int num, int default_fd)
{
for (int i = 0; i < num; i++){
fds[i].fd = default_fd;
fds[i].events = 0;
fds[i].revents = 0;
}
}
//设置fds数组
bool SetPollfds(struct pollfd fds[], int num, int fd)
{
for (int i = 0; i < num; i++){
if (fds[i].fd == DFL_FD){ //说明该位置没有被使用
fds[i].fd = fd;
fds[i].events |= POLLIN; //添加读事件到events中
return true;
}
}
return false; //说明fds 数组已满
}
//清除fds中的元素
void UnSetPollfds(struct pollfd fds[], int pos)
{
fds[pos].fd = DFL_FD;
fds[pos].events = 0;
fds[pos].revents = 0;
}
private:
int _listen_sock; //监听套接字
int _port; //端口号
};
最后,编写程序主函数:
- Main.cc
#include "PollServer.hpp"
#include <string>
static void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2){
Usage(argv[0]);
exit(1);
}
int port = atoi(argv[1]);
PollServer* svr = new PollServer(port);
svr->InitPollServer();
svr->Run();
return 0;
}
- Makefile
PollServer:Main.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f PollServer
编译后的程序名 + 端口号即可运行 select 服务器。
用 telnet 指令 + 127.0.0.1 + 端口号充当客户端程序,进行本机环回测试。
本机环回的窗口退出后,服务器会正常关闭对应的连接,并将对应的套接字从 fds 中清除。
.3- 优缺点
【Tips】poll的优点
- pollfd结构中包含了 events 和 revents,相当于将 select() 的输入输出型参数进行分离,使得在每次调用poll() 之前,不像 select() 一样需要重新对参数进行设置。
- 可以同时等待多个文件描述符,能够提高IO的效率。
- 可监控的文件描述符数量没有限制,这是因为 fds 数组的大小是可以增大的,具体要监视多少个文件描述符,由 poll() 的第二个参数决定的。
【Tips】poll的缺点
- 和 select() 一样,在返回后需要遍历 fds 数组来获取就绪的文件描述符。
- 每次调用 poll(),都需要把大量的 pollfd 结构从用户态拷贝到内核态,当待监视的文件描述符很多时,相应的开销会非常大。
- 每次调用 poll(),都需要在内核遍历传递进来的所有文件描述符,当待监视的文件描述符很多时,相应的开销会非常大。
3)epoll 系列
epoll 系列接口也可以让程序同时监视多个文件描述符上的事件是否就绪,与 select() 和 poll() 的定位大致相同。
epoll 在命名上比 poll() 多一个“e”(extend),相当于是改进版的 poll()。它在 2.5.44 内核中被引进,几乎具备了 select() 和 poll() 的所有优点,被公认为是 Linux2.6 下性能最好的多路 I/O 就绪通知方法。
epoll 系列下有三个系统调用,分别是 epoll_create()、epoll_ctl()、epoll_wait()。
【Tips】epoll的优点
- 接口使用方便:虽然拆分成了三个函数,但使用起来更方便、高效。
- 数据拷贝轻量:只在新增监视事件时,调用epoll_ctl将() 数据从用户拷贝到内核,而select() 和 poll() 每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用epoll_wait() 获取就绪事件时,只会拷贝就绪的事件,不会进行不必要的拷贝操作。
- 事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用 epoll_wait() 时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是 O(1),因为本质只需要判断就绪队列是否为空即可。
- 没有数量限制:监视的文件描述符数目无上限,只要内存允许,就可以一直向 epoll 模型封装的红黑树中新增节点。
- 无须自己维护事件的相关结构:在使用 select() 和 poll() 时,都需要借助第三方数组,来维护历史上的文件描述符以及需要监视的事件,这个第三方数组是由用户自己维护的,对该数组的增删改操作都需要用户自己来进行。而使用 epoll 时,不需要用户自己维护所谓的第三方数组,epoll 底层的红黑树就充当了这个第三方数组的功能,且该红黑树的增删改操作都是由内核维护的,用户只需要调用 epoll_ctl() 让内核对该红黑树进行对应的操作即可。
- 根据数据流方向将事件分离处理:在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select() 和 poll() 将这两件事情都交给了同一个函数来完成,而 epoll 在接口层面上就将这两件事进行了分离,epoll 通过调用 epoll_ctl() 完成用户告知内核,通过调用epoll_wait() 完成内核告知用户。
.1- 接口的基本信息
#include <sys/epoll.h>
int epoll_create(int size);
功能:创建一个epoll模型
参数:size:设置内核结构的大小。从Linux2.6.8之后参数已经是无效的了,但需要传入一个大于0的数。
返回值:创建成功则返回其对应的文件描述符,失败则返回- 1,并设置合适的错误码。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:向指定的 epoll 模型中注册事件
参数:1.epfd:指定的epoll模型。
2.op:表示具体的动作,用三个宏来表示,具体如下:
· EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中。
· EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。
· EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符。
3.fd:需要监视的文件描述符。
4.event:需要监视该文件描述符上的哪些事件。
返回值:成功则返回 0,失败则返回-1,并设置合适的错误码。
【补】epoll_event 结构
epoll_ctl() 的第四个参数是一个 epoll_event 结构类型的指针。
epoll_event 结构,即 epoll 模型,其在内核中的定义如下:
typedef union epoll_data { void *ptr; int fd; //需要监听的文件描述符 uint32_t u32; uint64_t u64; } epoll_data_t; //这是一个联合体,使得 epoll_data 可能不仅是描述符, //也可能是一个指针变量,由此拓宽了epoll 的使用场景。 struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
其中,成员 events 的常用取值如下:
- EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
- EPOLLOUT:表示对应的文件描述符可以写。
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
- EPOLLERR:表示对应的文件描述符发送错误。
- EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
- EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。
这些取值也是以宏的方式定义的,它们的二进制序列中有且仅有一个比特位为1,且为 1 的比特位各不相同。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能:在指定的 epoll 模型中,等待并收集已经就绪的事件
参数:1.epfd:指定的 epoll 模型。
2.events:存储就绪事件。内核会将已经就绪的事件拷贝到 events 数组中,但 events 不能是空指针,
内核只负责将就绪事件拷贝到该数组中,不会为其在用户态中分配内存。
3.maxevents:events 数组中的元素个数,该值不能大于创建 epoll 模型时传入的 size 值。
4.timeout:表示 epoll_wait() 的超时时间,单位是毫秒(ms)。
该参数的取值一般包括:
1)nullptr:select()调用后进行阻塞等待,
直到被监视的某个文件描述符上的某个事件就绪。
2)0:select()调用后进行非阻塞等待,
无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
3)特定的时间值:select()调用后在指定的时间内进行阻塞等待,
如果被监视的文件描述符上一直没有事件就绪,则在该时间后select进行超时返回。
返回值:调用成功,则返回事件就绪的文件描述符个数;
如果 timeout 时间耗尽,则返回 0;
调用失败,则返回 -1,并设置合适的错误码。错误码可能被设置为:
· EBADF:传入的epoll模型对应的文件描述符无效。
· EFAULT:events指向的数组空间无法通过写入权限访问。
· EINTR:此调用被信号所中断。
· EINVAL:epfd不是一个epoll模型对应的文件描述符,
或传入的maxevents值小于等于0。
.2- 实现原理
由接口的功能,epoll 系列接口的使用过程无非就是三步:
- 调用 epoll_create(),创建一个epoll模型。
- 调用 epoll_ctl(),将要监控的文件描述符进行注册。
- 调用 epoll_wait(),等待文件描述符就绪。
某一进程调用 epoll_create() 时,Linux 内核会创建一个 epoll 模型,即 event_poll 结构,其中的成员 rbr(红黑树)、rdlist(就绪队列) 与 epoll 的使用方式密切相关。也就是说,event_poll 结构中其实封装了一棵红黑树和一个就绪队列,其中,红黑树用于存储所有要监视的事件,就绪队列用于存储条件就绪的事件。
struct event_poll{
...
//红黑树的根节点,这棵树中存储着所有添加到epoll模型中的需要监视的事件
struct rb_root rbr;
//就绪队列中存放着将要通过epoll_wait()返回给用户的满足条件的事件
struct list_head rdlist;
...
}
epll_ctl() 可以向指定的 epoll 模型中注册事件,其实就是对 event_poll 结构中封装的红黑树进行增删改查。
epoll_wait() 可以在指定的 epoll 模型中,等待并收集已经就绪的事件,其实就是对 event_poll 结构中封装的就绪队列进行增删改查。
此外,在 epoll 模型中,每一个事件都有一个对应的 epitem 结构体,红黑树和就绪队列中的节点,分别就是基于 epitem 结构中的 rbn 成员和 rdllink 成员来构建的,其结构定义如下:
struct epitem{
struct rb_node rbn; //红黑树的节点
struct list_head rdllink; //就绪队列的节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的event_poll对象
struct epoll_event event; //期待发生的事件类型
//...
}
ffd 与 event的含义,对于 rbn 成员来说是,需要监视 ffd 上的 event 事件是否就绪;而对于 rdlink 成员来说则是,ffd 上的 event 事件已经就绪了。
所有添加到红黑树中的事件,都会与设备(网卡)驱动程序自动建立回调方法,这个回调方法在内核中为 ep_poll_callback()。只有当红黑树中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列中。
对于 select() 和 poll() 来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,但这一定会增加操作系统的负担。而对于 epoll 系列来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。
因此,当用户调用 epoll_wait() 获取就绪事件时,只需要关注底层就绪队列是否为空,若不为空,则将就绪队列中的就绪事件拷贝下来即可。
当不断有监视的事件就绪时,系统会不断调用回调方法,向就绪队列中插入节点,而上层也会不断调用epoll_wait() 从就绪队列中获取节点,这也是一种典型的生产者消费者模型。由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,eventpoll 结构中的 lock 和 mtx 就是用于保护临界资源的,使得 epoll 本身是线程安全的。eventpoll 结构当中的 wq(wait queue)就是等待队列,当多个执行流想要同时访问同一个 epoll 模型时,就需要在该等待队列下进行等待。
.3- 工作模式
epoll 有两种工作模式,分别是水平触发(LT)和边缘触发(ET)。
【Tips】水平触发(LT,Level Triggered)
LT 工作模式既支持阻塞读写,又支持非阻塞读写。只要底层有事件就绪,epoll 就会一直通知用户,就像数字电路中的高电平触发一样,只要一直处于高电平,则会一直触发。
一般 epoll 的工作模式默认为 LT。由于在 LT 工作模式下,只要底层有事件就绪,就会一直通知用户,因此当 epoll 检测到底层读事件就绪时,可以不立即进行处理,或只处理一部分,因为只要底层数据没有处理完,下一次 epoll 还会通知用户事件就绪。
此外,select() 和 poll() 其实也是 LT 工作模式下的。
【Tips】边缘触发(ET,Edge Triggered)
ET 工作模式只支持非阻塞的读写。只有底层就绪事件的数量,由无到有或由有到多地变化时,epoll 才会通知用户,就像数字电路当中的上升沿触发一样,只有电平由低变高的瞬间才会触发。
一般 epoll 的工作模式默认不为 ET,要将 epoll 改为 ET 工作模式,就需要在调用 epoll_ctl() 添加事件时设置 EPOLLET 选项。
由于在 ET 工作模式下,只有底层就绪事件的数量,由无到有或由有到多地变化时,epoll 才会通知用户,因此当 epoll 检测到底层读事件就绪时,必须立即进行处理,且必须全部处理完毕,因为可能此后底层再也没有事件就绪,那么 epoll 就再也不会通知用户进行事件处理,于是没有处理完的数据就会丢失了。
ET 工作模式下,epoll 通知用户的次数一般比 LT 更少,因此 ET 的性能一般也比 LT 更高。
【补】ET 工作模式下如何进行读写?
ET 工作模式下,recv() 和 send() 所操作的文件描述符,必须设置为非阻塞状态,且在读数据时,必须循环调用 recv() 进行读取,在写数据时,必须循环调用 send() 进行写入。
由于在 ET 工作模式下,只有底层就绪事件的数量,由无到有或由有到多地变化时,才会通知用户,这就倒逼着用户,在读事件就绪时必须一次性将数据全部读取完毕,在写事件就绪时必须一次性将发送缓冲区写满,否则就可能再也没有机会进行读写了;因此,读数据时,必须循环调用 recv() 进行读取,写数据时,必须循环调用 send() 进行写入。
当底层读事件就绪时,循环调用 recv() 进行读取,直到某次调用 recv() 读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已经读取完毕了。但有可能最后一次调用 recv() 读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,如果再调用 recv() 进行读取,recv() 就会因为底层没有数据而被阻塞住。而这里的阻塞是非常严重的,例如在单进程的服务器中,如果 recv() 被阻塞住,且此后该数据再也不就绪,那么就相当于服务器挂掉了。因此,在ET工作模式下循环调用 recv() 进行读取时,必须将对应的文件描述符设置为非阻塞状态。
同理,进行数据的写入需要循环调用send(),且必须将对应的文件描述符设置为非阻塞状态。
【补】ET vs LT
在 ET 模式下,一个文件描述符就绪之后,用户不会反复收到通知,虽然看起来比 LT 更高效,但如果在 LT 模式下,能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实 LT 和 ET 的性能也是一样的。
虽然 ET 的编程难度也比 LT 更高,但 ET 模式下可以避免某些情况下的无效通知,因此在实际使用中,ET 模式更常用,尤其是在高并发场景下。
.4- epoll LT 服务端
与上文的 select 服务器、poll 服务器类似,此处 epoll 服务器的基本功能是,有客户端发来数据、可以读取数据了(读事件就绪),就读取和打印客户端发来的数据。
首先单独编写一个 Socket 类,对套接字相关的接口进行封装:
- Socket.hpp
//ps:为了让外部能直接调用 Socket 类的成员函数,此处将它们定义成为静态的
#pragma once
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdlib>
class Socket{
public:
//创建套接字
static int SocketCreate()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//设置端口复用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
//绑定
static void SocketBind(int sock, int port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
socklen_t len = sizeof(local);
if (bind(sock, (struct sockaddr*)&local, len) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
}
//监听
static void SocketListen(int sock, int backlog)
{
if (listen(sock, backlog) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
};
然后单独编写一个 PollServer 类,作为 epoll 服务器的主体:
- EpollServer.hpp
#include "Socket.hpp"
#include <sys/epoll.h>
#define BACK_LOG 5 //全连接队列的大小
#define SIZE 256 //epoll模型的大小
#define MAX_NUM 64 //就绪队列的长度
class EpollServer{
public:
EpollServer(int port)
: _port(port)
{}
//初始化 epoll 服务器
void InitEpollServer()
{
//初始化套接字
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
//创建epoll模型
_epfd = epoll_create(SIZE);
if (_epfd < 0){
std::cerr << "epoll_create error" << std::endl;
exit(5);
}
}
~EpollServer()
{
if (_listen_sock >= 0){
close(_listen_sock);
}
if (_epfd >= 0){
close(_epfd);
}
}
//检测事件是否就绪
void Run()
{
//将监听套接字添加到epoll模型中
AddEvent(_listen_sock, EPOLLIN);
while(1){
//等待并收集已经就绪的事件
struct epoll_event revs[MAX_NUM];
int num = epoll_wait(_epfd, revs, MAX_NUM, -1);
if (num < 0){
std::cerr << "epoll_wait error" << std::endl;
continue;
}
else if (num == 0){
std::cout << "timeout..." << std::endl;
continue;
}
else{
//正常的事件处理
std::cout<<"something happening..."<<std::endl;
HandlerEvent(revs, num);
}
}
}
//处理就绪事件
void HandlerEvent(struct epoll_event revs[], int num)
{
//从epoll模型中不停获取就绪事件并处理
for (int i = 0; i < num; i++){
int fd = revs[i].data.fd; //就绪的文件描述符
//连接事件就绪
if (fd == _listen_sock&&revs[i].events&EPOLLIN){
//获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
//将获取到的套接字添加到epoll模型中
AddEvent(sock, EPOLLIN);
}
//读事件就绪
else if (revs[i].events&EPOLLIN){
char buffer[64];
ssize_t size = recv(fd, buffer, sizeof(buffer)-1, 0);
//读取成功
if (size > 0){
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
//对端连接关闭
else if (size == 0){
std::cout << "client quit" << std::endl;
close(fd);
DelEvent(fd); //将文件描述符从epoll模型中删除
}
//读取失败
else{
std::cerr << "recv error" << std::endl;
close(fd);
DelEvent(fd); //将文件描述符从epoll模型中删除
}
}
}
}
private:
//添加就绪事件到epoll模型中
void AddEvent(int sock, uint32_t event)
{
struct epoll_event ev;
ev.events = event;
ev.data.fd = sock;
epoll_ctl(_epfd, EPOLL_CTL_ADD, sock, &ev);
}
//将就绪事件从epoll模型中删除
void DelEvent(int sock)
{
epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);
}
private:
int _listen_sock; //监听套接字
int _port; //端口号
int _epfd; //epoll模型
};
最后,编写程序主函数:
- Main.cc
#include "EpollServer.hpp"
#include <string>
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2){
Usage(argv[0]);
exit(1);
}
int port = atoi(argv[1]);
EpollServer* svr = new EpollServer(port);
svr->InitEpollServer();
svr->Run();
return 0;
}
- Makefile
EpollServer:Main.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f EpollServer
编译后的程序名 + 端口号即可运行 select 服务器。
用 telnet 指令 + 127.0.0.1 + 端口号充当客户端程序,进行本机环回测试。
本机环回的窗口退出后,服务器会正常关闭对应的连接。
三、Reactor 模式
1)定义
Reactor 模式被称为反应器模式或应答者模式,是基于事件驱动的设计模式,拥有一个或多个并发输入源,还拥有一个服务处理器和多个请求处理器。其中,服务处理器会同步的将输入的请求事件以多路复用的方式分发给相应的请求处理器。
事件驱动(Event-Driven)编程是一种编程范式,基于事件的发生和处理,来组织程序的逻辑。也就是说,在事件驱动编程中,程序的执行流程是由事件的发生和处理来驱动的,体现在在事件驱动的应用中,就是将一个或多个客户端的请求分离和调度给应用程序,同步有序地接收并处理多个服务请求。
高并发系统经常会使用到 Reactor 模式,用来替代常用的多线程处理方式,以节省系统资源并提高系统的吞吐量。
【补】事件驱动编程
在事件驱动编程中,主要包含以下几个概念:
- 事件(Event):事件是程序中可以触发或监听的某种动作或状态变化,例如用户点击鼠标、键盘输入等。
- 事件源(Event Source):事件源是产生事件的对象或组件,比如说网络连接中的客户端。
- 事件处理器(Event Handler):事件处理器是负责处理特定类型事件的函数或方法。当事件发生时,事件处理器会被调用来处理事件。
- 事件循环(Event Loop):事件循环负责监听各种事件的发生,并调用相应的事件处理器来处理事件。它通常是一个无限循环,不断地检查事件队列中是否有待处理的事件。
从网络的角度来说,事件源就是服务端保存的客户端的连接,事件就是客户端是否向服务器发起连接或发送请求,事件处理器就是建立连接或者处理请求并发送应答,事件循环就是使用多路转接函数死循环地不断检查事件是否就绪。
一般的处理模式,采用阻塞 I/O 的方式处理每一个线程的输入输出,每一个请求应用程序都会为其创建一个新的线程,使用完毕之后会将线程进行释放。但采用阻塞式会导致等待的时间过长,进而导致线程资源的利用率降低,浪费线程资源;且大量的连接到来时,可能会创建大量的线程,过度占用系统资源;而反复的申请和释放线程也会消耗一定的系统资源。
单 Recator 单线程的处理模式,通常采用 I/O 多路转接的方式,提高IO处理的效率,其间只采用一个线程,全权负责对事件的等待、派发、处理等工作,实现较为简单。但对于多核 CPU 来说,一次能跑多个线程,只用一个线程,不利于释放 CPU 的性能;且从安全性的角度来讲,只要这一个线程出异常,那么整个程序都将无法正常运转,进而导致程序的瘫痪。
单 Recator 多线程的处理模式,在单Recator单线程的处理模式的基础之上,引入了多线程即线程池,提高了并发度,有利于释放CPU的性能,尽管多线程的处理逻辑一般是比较复杂的,不易于调试。
2)角色构成与工作流程
Reactor 模式主要由以下五个角色构成:
Reactor 模式大致的工作流程如下:
- 当应用向初始分发器注册具体事件处理器时,应用会标识出该事件处理器希望初始分发器在某个事件发生时向其通知,该事件与 Handle 关联。
- 初始分发器会要求每个事件处理器向其传递内部的 Handle,该 Handle 向操作系统标识了事件处理器。
- 当所有的事件处理器注册完毕后,应用会启动初始分发器的事件循环,这时初始分发器会将每个事件处理器的 Handle 合并起来,并使用同步事件分离器等待这些事件的发生。
- 当某个事件处理器的 Handle 变为 Ready 状态时,同步事件分离器会通知初始分发器。
- 初始分发器会将 Ready 状态的 Handle 作为 key,来寻找其对应的事件处理器。
- 初始分发器会调用其对应事件处理器当中对应的回调方法来响应该事件。