当前位置: 首页 > news >正文

展厅设计找哪家公司好seo百度关键词排名

展厅设计找哪家公司好,seo百度关键词排名,网站建站卡顿怎么办,wordpress oss静态摘于https://subingwen.cn,作者:苏丙榅 侵删 文章目录 1. 套接字-socket1.1 概念1.2 网络协议1.3 socket编程1.3.1 字节序1.3.2 IP地址转换1.3.3 sockaddr 数据结构1.3.4 套接字函数 1.4 TCP通信流程1.4.1 服务器端通信流程1.4.2 客户端的通信流程 1.5 扩展阅读1.5.1 初始化套…摘于https://subingwen.cn,作者:苏丙榅 侵删 文章目录 1. 套接字-socket1.1 概念1.2 网络协议1.3 socket编程1.3.1 字节序1.3.2 IP地址转换1.3.3 sockaddr 数据结构1.3.4 套接字函数 1.4 TCP通信流程1.4.1 服务器端通信流程1.4.2 客户端的通信流程 1.5 扩展阅读1.5.1 初始化套接字环境1.5.2 套接字通信函数1.5.2.1 结构体1.5.2.2 大小端转换函数1.5.2.3 套接字函数 2. 三次握手,四次挥手2.1 tcp协议介绍2.2 三次握手2.2 TCP四次挥手2.3 流量控制 3. TCP状态转换3.1 TCP状态转换3.1.1 三次握手3.1.2 四次挥手3.1.3 状态转换3.1.4 相关命令 3.2 半关闭3.3 端口复用 4. 服务器并发4.1 单线程/进程4.2 多进程并发4.3 多线程并发 5. TCP数据粘包的处理5.1 TCP5.2 解决方案5.2.1 发送端5.2.2 接收端 6. 套接字通信类的封装6.1 基于C语言的封装6.1.1 函数声明6.1.2 函数定义 6.2 基于C的封装6.2.1 版本16.2.1.1 客户端6.2.1.2 服务器端 6.2.2 版本26.2.2.1 通信类6.2.2.2 服务器类 6.3 测试代码6.3.1 客户端6.3.2 服务器端 7. IO多路转接复用之select7.1 IO多路转接(复用)7.2 select7.2.1 函数原型7.2.2 细节描述 7.3 并发处理7.3.1 处理流程7.3.2 通信代码 8. IO多路转接复用之poll8.1 poll函数8.2 测试代码 9. IO多路转接复用之epoll9.1 概述9.2 操作函数9.3 epoll的使用9.3.1 操作步骤9.3.2 示例代码 9.4 epoll的工作模式9.4.1 水平模式9.4.2 边沿模式9.4.2.1 ET模式的设置9.4.2.2 设置非阻塞9.4.2.3 示例代码 10. 基于UDP的套接字通信10.1 通信流程10.1.1 服务器端10.1.2 客户端 10.2 通信函数10.3 通信代码10.3.1 服务器端10.3.2 客户端 11. UDP特性之广播11.1 广播的特点11.2 设置广播属性11.3 广播通信流程11.4 通信代码 12. UDP特性之组播多播12.1 组播的特点12.2 设置组播属性12.2.1 发送端12.2.2 接收端 12.3 组播通信流程12.3.1 发送端12.3.2 接收端 12.4 通信代码12.4.1 发送端12.4.2 接收端 1. 套接字-socket 1.1 概念 局域网和广域网 局域网局域网将一定区域内的各种计算机、外部设备和数据库连接起来形成计算机通信的私有网络。广域网又称广域网、外网、公网。 是连接不同地区局域网或城域网计算机通信的远程公共网络。 IPInternet Protocol本质是一个整形数用于表示计算机在网络中的地址。 IP协议版本有两个IPv4和IPv6 IPv4Internet Protocol version4 使用一个32位的整形数描述一个IP地址4个字节int型也可以使用一个点分十进制字符串描述这个IP地址 192.168.130.198分成了4份每份1字节8bitchar最大值为 255 0.0.0.0 是最小的IP地址255.255.255.255是最大的IP地址 按照IPv4协议计算可以使用的IP地址共有 232 个 IPv6Internet Protocol version6 使用一个128位的整形数描述一个IP地址16个字节也可以使用一个字符串描述这个IP地址2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b分成了8份每份2字节每一部分以16进制的方式表示按照IPv6协议计算可以使用的IP地址共有 2128 个 查看IP地址 # linux $ ifconfig# windows $ ipconfig# 测试网络是否畅通 # 主机a: 192.168.1.11 # 当前主机: 192.168.1.12 $ ping 192.168.1.11 # 测试是否可用连接局域网 $ ping www.baidu.com # 测试是否可用连接外网# 特殊的IP地址: 127.0.0.1 和本地的IP地址是等价的 # 假设当前电脑没有联网, 就没有IP地址, 又要做网络测试, 可用使用 127.0.0.1 进行本地测试端口 端口的作用是定位到主机上的某一个进程通过这个端口进程就可接受到对应的网络数据。 比如: 在电脑上运行了微信和QQ, 小明通过客户端给我的的微信发消息, 电脑上的微信就收到了消息, 为什么? 运行在电脑上的微信和QQ都绑定了不同的端口 通过IP地址可以定位到某一台主机 通过端口就可以定位到主机上的某一个进程 通过指定的IP和端口发送数据的时候对端就能接受到数据了 端口也是一个整形数 unsigned short 一个16位整形数有效端口的取值范围是 0 ~ 65535(0 ~ 216-1) 计算机中所有的进程都需要关联一个端口吗? 不需要如果这个进程不需要网络通信那么这个进程就不需要绑定端口的 . 一个端口可以被重复使用吗? 一个端口只能给某一个进程使用多个进程不能同时使用同一端口 OSI/ISO 网络分层模型 OSIOpen System Interconnect即开放式系统互联。 一般都叫OSI参考模型是ISO国际标准化组织组织在1985年研究的网络互联模型。 物理层负责最后将信息编码成电流脉冲或其它信号用于网上传输数据链路层: 数据链路层通过物理网络链路供数据传输。规定了0和1的分包形式确定了网络数据包的形式 网络层 网络层负责在源和终点之间建立连接;此处需要确定计算机的位置通过IPv4IPv6格式的IP地址来找到对应的主机 传输层 传输层向高层提供可靠的端到端的网络数据流服务。每一个应用程序都会在网卡注册一个端口号该层就是端口与端口的通信 会话层 会话层建立、管理和终止表示层与实体之间的通信会话建立一个连接自动的手机信息、自动的网络寻址; 表示层: 对应用层数据编码和转化, 确保以一个系统应用层发送的信息 可以被另一个系统应用层识别; 1.2 网络协议 网络协议指的是计算机网络中互相通信的对等实体之间交换信息时所必须遵守的规则的集合。 一般系统网络协议包括五个部分通信环境传输服务词汇表信息的编码格式时序、规则和过程。 通过几幅图了解下常用的网络协议的格式 TCP协议 - 传输层协议 UDP协议 - 传输层协议 IP协议(IPV4) - 网络层协议 以太网帧协议 - 网络接口层协议 数据的封装 在网络通信的时候, 我们需负责的应用层数据的处理(最上层) 应用层的数据可以使用某些协议进行封装, 也可以不封装我们需要调用发送数据的接口函数将数据发送出去我们调用的API做底层数据处理 传输层使用传输层协议打包数据网络层使用网络层协议打包数据网络接口层使用网络接口层协议打包数据数据被发送到internet 接收端接收到发送端的数据 我们调用接收数据的函数接收数据调用的API做相关的底层处理: 网络接口层拆包 网络层的包网络层拆包 网络层的包传输层拆包 传输层数据 如果应用层也使用了协议对数据进行了封装数据的包的解析需要我们做 1.3 socket编程 Socket套接字由远景研究规划局Advanced Research Projects Agency, ARPA资助加里福尼亚大学伯克利分校的一个研究组研发。 其目的是将TCP/IP协议相关软件移植到UNIX类系统中。设计者开发了一个接口以便应用程序能简单地调用该接口通信。 这个接口不断完善最终形成了Socket套接字。Linux系统采用了Socket套接字因此Socket接口就被广泛使用到现在已经成为事实上的标准。 与套接字相关的函数被包含在头文件sys/socket.h中。 套接字对我们来说就是一套网络通信的接口使用这套接口就可以完成网络通信。 网络通信的主体主要分为两部分客户端和服务器端。 在客户端和服务器通信的时候需要频繁提到三个概念IP、端口、通信数据 1.3.1 字节序 在各种计算机体系结构中对于字节、字等的存储机制有所不同因而引发了计算机通信领域中一个很重要的问题即通信双方交流的信息单元比特、字节、字、双字等等应该以什么样的顺序进行传送。如果不达成一致的规则通信双方将无法进行正确的编/译码从而导致通信失败。 字节序顾名思义字节的顺序就是大于一个字节类型的数据在内存中的存放顺序 也就是说对于单字符来说是没有字节序问题的字符串是单字符的集合因此字符串也没有字节序问题。 目前在各种体系的计算机中通常采用的字节存储机制主要有两种Big-Endian 和 Little-Endian 下面先从字节序说起。 大小端的这个名词最早出现在《格列佛游记》中里边记载了两个征战的强国你不会想到的是他们打仗竟然和剥鸡蛋的顺序有关。很多人认为剥鸡蛋时应该打破鸡蛋较大的一端这群人被称作“大端Big endian派”。可是那时皇帝儿子小时候吃鸡蛋的时候碰巧将一个手指弄破了。所以当时的皇帝就下令剥鸡蛋必须打破鸡蛋较小的一端违令者重罚由此产生了“小端Little endian派”。 老百姓们对这项命令极其反感由此引发了6次叛乱其中一个皇帝送了命另一个丢了王位。据估计先后几次有11000人情愿受死也不肯去打破鸡蛋较小的一端 Little-Endian - 主机字节序 (小端) 数据的低位字节存储到内存的低地址位, 数据的高位字节存储到内存的高地址位我们使用的PC机数据的存储默认使用的是小端 Big-Endian - 网络字节序 (大端) 数据的低位字节存储到内存的高地址位, 数据的高位字节存储到内存的低地址位套接字通信过程中操作的数据都是大端存储的包括接收/发送的数据,IP地址,端口 字节序举例 // 有一个16进制的数, 有32位 (int): 0xab5c01ff // 字节序, 最小的单位: char 字节, int 有4个字节, 需要将其拆分为4份 // 一个字节 unsigned char, 最大值是 255(十进制) ff(16进制) 内存低地址位 内存的高地址位 --------------------------------------------------------------------------- 小端: 0xff 0x01 0x5c 0xab 大端: 0xab 0x5c 0x01 0xff函数 BSD Socket提供了封装好的转换接口方便我们使用。 包括从主机字节序到网络字节序的转换函数htons、htonl 从网络字节序到主机字节序的转换函数ntohs、ntohl。 #include arpa/inet.h // u:unsigned // 16: 16位, 32:32位 // h: host, 主机字节序 // n: net, 网络字节序 // s: short // l: int// 这套api主要用于 网络通信过程中 IP 和 端口 的 转换 // 将一个短整形从主机字节序 - 网络字节序 uint16_t htons(uint16_t hostshort); // 将一个整形从主机字节序 - 网络字节序 uint32_t htonl(uint32_t hostlong); // 将一个短整形从网络字节序 - 主机字节序 uint16_t ntohs(uint16_t netshort) // 将一个整形从网络字节序 - 主机字节序 uint32_t ntohl(uint32_t netlong);1.3.2 IP地址转换 虽然IP地址本质是一个整形数但是在使用的过程中都是通过一个字符串来描述 下面的函数描述了如何将一个字符串类型的IP地址进行大小端转换 // 主机字节序的IP地址转换为网络字节序 // 主机字节序的IP地址是字符串, 网络字节序IP地址是整形 int inet_pton(int af, const char *src, void *dst); 参数: af: 地址族(IP地址的家族包括ipv4和ipv6)协议 AF_INET: ipv4格式的ip地址AF_INET6: ipv6格式的ip地址 src: 传入参数, 对应要转换的点分十进制的ip地址: 192.168.1.100dst: 传出参数, 函数调用完成, 转换得到的大端整形IP被写入到这块内存中 返回值成功返回1失败返回0或者-1 #include arpa/inet.h // 将大端的整形数, 转换为小端的点分十进制的IP地址 const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);参数 af: 地址族协议 AF_INET: ipv4格式的ip地址AF_INET6: ipv6格式的ip地址 src: 传入参数, 这个指针指向的内存中存储了大端的整形IP地址dst: 传出参数, 存储转换得到的小端的点分十进制的IP地址size: 修饰dst参数的, 标记dst指向的内存中最多可以存储多少个字节 返回值: 成功: 指针指向第三个参数对应的内存地址, 通过返回值也可以直接取出转换得到的IP字符串失败: NULL 还有一组函数也能进程IP地址大小端的转换但是只能处理ipv4的ip地址 // 点分十进制IP - 大端整形 in_addr_t inet_addr (const char *cp);// 大端整形 - 点分十进制IP char* inet_ntoa(struct in_addr in);1.3.3 sockaddr 数据结构 // 在写数据的时候不好用 struct sockaddr {sa_family_t sa_family; // 地址族协议, ipv4char sa_data[14]; // 端口(2字节) IP地址(4字节) 填充(8字节) }typedef unsigned short uint16_t; typedef unsigned int uint32_t; typedef uint16_t in_port_t; typedef uint32_t in_addr_t; typedef unsigned short int sa_family_t; #define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))struct in_addr {in_addr_t s_addr; }; // sizeof(struct sockaddr) sizeof(struct sockaddr_in) struct sockaddr_in {sa_family_t sin_family; /* 地址族协议: AF_INET */in_port_t sin_port; /* 端口, 2字节- 大端 */struct in_addr sin_addr; /* IP地址, 4字节 - 大端 *//* 填充 8字节 */unsigned char sin_zero[sizeof (struct sockaddr) - sizeof(sin_family) -sizeof (in_port_t) - sizeof (struct in_addr)]; }; 1.3.4 套接字函数 使用套接字通信函数需要包含头文件arpa/inet.h包含了这个头文件sys/socket.h就不用在包含了。 // 创建一个套接字 int socket(int domain, int type, int protocol);参数: domain: 使用的地址族协议 AF_INET: 使用IPv4格式的ip地址AF_INET6: 使用IPv4格式的ip地址 type: SOCK_STREAM: 使用流式的传输协议SOCK_DGRAM: 使用报式(报文)的传输协议 protocol: 一般写0即可, 使用默认的协议 SOCK_STREAM: 流式传输默认使用的是tcpSOCK_DGRAM: 报式传输默认使用的udp 返回值: 成功: 可用于套接字通信的文件描述符失败: -1 函数的返回值是一个文件描述符通过这个文件描述符可以操作内核中的某一块内存网络通信是基于这个文件描述符来完成的。 // 将文件描述符和本地的IP与端口进行绑定 int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);参数: sockfd: 监听的文件描述符, 通过socket()调用得到的返回值addr: 传入参数, 要绑定的IP和端口信息需要初始化到这个结构体中IP和端口要转换为网络字节序addrlen: 参数addr指向的内存大小, sizeof(struct sockaddr) 返回值成功返回0失败返回-1 // 给监听的套接字设置监听 int listen(int sockfd, int backlog);参数: sockfd: 文件描述符, 可以通过调用socket()得到在监听之前必须要绑定 bind()backlog: 同时能处理的最大连接要求最大值为128 返回值函数调用成功返回0调用失败返回 -1 // 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的) int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);参数: sockfd: 监听的文件描述符addr: 传出参数, 里边存储了建立连接的客户端的地址信息addrlen: 传入传出参数用于存储addr指向的内存大小 返回值函数调用成功得到一个文件描述符, 用于和建立连接的这个客户端通信 调用失败返回 -1 这个函数是一个阻塞函数当没有新的客户端连接请求的时候该函数阻塞当检测到有新的客户端连接请求时阻塞解除新连接就建立了得到的返回值也是一个文件描述符基于这个文件描述符就可以和客户端通信了。 // 接收数据 ssize_t read(int sockfd, void *buf, size_t size); ssize_t recv(int sockfd, void *buf, size_t size, int flags);参数: sockfd: 用于通信的文件描述符, accept() 函数的返回值buf: 指向一块有效内存, 用于存储接收是数据size: 参数buf指向的内存的容量flags: 特殊的属性, 一般不使用, 指定为 0 返回值: 大于0实际接收的字节数等于0对方断开了连接-1接收数据失败了 如果连接没有断开接收端接收不到数据接收数据的函数会阻塞等待数据到达数据到达后函数解除阻塞开始接收数据 当发送端断开连接接收端无法接收到任何数据但是这时候就不会阻塞了函数直接返回0。 // 发送数据的函数 ssize_t write(int fd, const void *buf, size_t len); ssize_t send(int fd, const void *buf, size_t len, int flags);参数: fd: 通信的文件描述符, accept() 函数的返回值buf: 传入参数, 要发送的字符串len: 要发送的字符串的长度flags: 特殊的属性, 一般不使用, 指定为 0 返回值 大于0实际发送的字节数和参数len是相等的-1发送数据失败了 // 成功连接服务器之后, 客户端会自动随机绑定一个端口 // 服务器端调用accept()的函数, 第二个参数存储的就是客户端的IP和端口信息 int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);参数: sockfd: 通信的文件描述符, 通过调用socket()函数就得到了addr: 存储了要连接的服务器端的地址信息: iP 和 端口这个IP和端口也需要转换为大端然后再赋值addrlen: addr指针指向的内存的大小 sizeof(struct sockaddr) 返回值连接成功返回0连接失败返回-1 1.4 TCP通信流程 TCP是一个面向连接的安全的流式传输协议这个协议是一个传输层协议。 面向连接是一个双向连接通过三次握手完成断开连接需要通过四次挥手完成。安全tcp通信过程中,会对发送的每一数据包都会进行校验, 如果发现数据丢失, 会自动重传流式传输发送端和接收端处理数据的速度数据的量都可以不一致 1.4.1 服务器端通信流程 创建用于监听的套接字, 这个套接字是一个文件描述符 int lfd socket();将得到的监听的文件描述符和本地的IP 端口进行绑定 bind();设置监听(成功之后开始监听, 监听的是客户端的连接) listen();等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的)没有新连接请求就阻塞 int cfd accept();通信读写操作默认都是阻塞的 // 接收数据 read(); / recv(); // 发送数据 write(); / send();断开连接, 关闭套接字 close();在tcp的服务器端, 有两类文件描述符 监听的文件描述符 只需要有一个不负责和客户端通信, 负责检测客户端的连接请求, 检测到之后调用accept就可以建立新的连接 通信的文件描述符 负责和建立连接的客户端通信如果有N个客户端和服务器建立了新的连接,通信的文件描述符就有N个每个客户端和服务器都对应一个通信的文件描述符 文件描述符对应的内存结构 一个文件文件描述符对应两块内存, 一块内存是读缓冲区, 一块内存是写缓冲区读数据: 通过文件描述符将内存中的数据读出, 这块内存称之为读缓冲区写数据: 通过文件描述符将数据写入到某块内存中, 这块内存称之为写缓冲区 监听的文件描述符: 客户端的连接请求会发送到服务器端监听的文件描述符的读缓冲区中读缓冲区中有数据, 说明有新的客户端连接调用accept()函数, 这个函数会检测监听文件描述符的读缓冲区 检测不到数据, 该函数阻塞如果检测到数据, 解除阻塞, 新的连接建立 通信的文件描述符: 客户端和服务器端都有通信的文件描述符发送数据调用函数 write() / send()数据进入到内核中 数据并没有被发送出去, 而是将数据写入到了通信的文件描述符对应的写缓冲区中内核检测到通信的文件描述符写缓冲区中有数据, 内核会将数据发送到网络中 接收数据: 调用的函数 read() / recv(), 从内核读数据 数据如何进入到内核我们不需要处理, 数据进入到通信的文件描述符的读缓冲区中数据进入到内核, 必须使用通信的文件描述符, 将数据从读缓冲区中读出即可 基于tcp的服务器端通信代码: // server.c #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建监听的套接字int lfd socket(AF_INET, SOCK_STREAM, 0);if(lfd -1){perror(socket);exit(0);}// 2. 将socket()返回值和本地的IP端口绑定到一起struct sockaddr_in addr;addr.sin_family AF_INET;addr.sin_port htons(10000); // 大端端口// INADDR_ANY代表本机的所有IP, 假设有三个网卡就有三个IP地址// 这个宏可以代表任意一个IP地址// 这个宏一般用于本地的绑定操作addr.sin_addr.s_addr INADDR_ANY; // 这个宏的值为0 0.0.0.0 // inet_pton(AF_INET, 192.168.237.131, addr.sin_addr.s_addr);int ret bind(lfd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(bind);exit(0);}// 3. 设置监听ret listen(lfd, 128);if(ret -1){perror(listen);exit(0);}// 4. 阻塞等待并接受客户端连接struct sockaddr_in cliaddr;int clilen sizeof(cliaddr);int cfd accept(lfd, (struct sockaddr*)cliaddr, clilen);if(cfd -1){perror(accept);exit(0);}// 打印客户端的地址信息char ip[24] {0};printf(客户端的IP地址: %s, 端口: %d\n,inet_ntop(AF_INET, cliaddr.sin_addr.s_addr, ip, sizeof(ip)),ntohs(cliaddr.sin_port));// 5. 和客户端通信while(1){// 接收数据char buf[1024];memset(buf, 0, sizeof(buf));int len read(cfd, buf, sizeof(buf));if(len 0){printf(客户端say: %s\n, buf);write(cfd, buf, len);}else if(len 0){printf(客户端断开了连接...\n);break;}else{perror(read);break;}}close(cfd);close(lfd);return 0; }1.4.2 客户端的通信流程 在单线程的情况下客户端通信的文件描述符有一个, 没有监听的文件描述符 创建一个通信的套接字 int cfd socket();连接服务器, 需要知道服务器绑定的IP和端口 connect();通信 // 接收数据 read(); / recv(); // 发送数据 write(); / send();断开连接, 关闭文件描述符(套接字) close();基于tcp通信的客户端通信代码: // client.c #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建通信的套接字int fd socket(AF_INET, SOCK_STREAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 连接服务器struct sockaddr_in addr;addr.sin_family AF_INET;addr.sin_port htons(10000); // 大端端口inet_pton(AF_INET, 192.168.237.131, addr.sin_addr.s_addr);int ret connect(fd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(connect);exit(0);}// 3. 和服务器端通信int number 0;while(1){// 发送数据char buf[1024];sprintf(buf, 你好, 服务器...%d\n, number);write(fd, buf, strlen(buf)1);// 接收数据memset(buf, 0, sizeof(buf));int len read(fd, buf, sizeof(buf));if(len 0){printf(服务器say: %s\n, buf);}else if(len 0){printf(服务器断开了连接...\n);break;}else{perror(read);break;}sleep(1); // 每隔1s发送一条数据}close(fd);return 0; }1.5 扩展阅读 在Window中也提供了套接字通信的API这些API函数与Linux平台的API函数几乎相同以至于很多人认为套接字通信的API函数库只有一套看一下这些Windows平台的套接字函数 1.5.1 初始化套接字环境 使用Windows中的套接字函数需要额外包含对应的头文件以及加载响应的动态库 // 使用包含的头文件 include winsock2.h // 使用的套接字库 ws2_32.dll 在Windows中使用套接字需要先加载套接字库套接字环境最后需要释放套接字资源。 // 初始化Winsock库 // 返回值: 成功返回0失败返回SOCKET_ERROR。 WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);参数: wVersionRequested: 使用的Windows Socket的版本, 一般使用的版本是 2.2 初始化这个 MAKEWORD(2, 2);参数 lpWSAData一个WSADATA结构指针, 这是一个传入参数 创建一个 WSADATA 类型的变量, 将地址传递给该函数的第二个参数 注销Winsock相关库函数调用成功返回0失败返回 SOCKET_ERROR。 int WSACleanup (void);使用举例 WSAData wsa; // 初始化套接字库 WSAStartup(MAKEWORD(2, 2), wsa);// .......// 注销Winsock相关库 WSACleanup();1.5.2 套接字通信函数 基于Linux的套接字通信流程是最全面的一套通信流程如果是在某个框架中进行套接字通信通信流程只会更简单直接使用window的套接字api进行套接字通信和Linux平台上的通信流程完全相同。 1.5.2.1 结构体 /// /// Windows /// /// typedef struct in_addr {union {struct{ unsigned char s_b1,s_b2, s_b3,s_b4;} S_un_b;struct{ unsigned short s_w1, s_w2;} S_un_w;unsigned long S_addr; // 存储IP地址} S_un; }IN_ADDR;struct sockaddr_in {short int sin_family; /* Address family */unsigned short int sin_port; /* Port number */struct in_addr sin_addr; /* Internet address */unsigned char sin_zero[8]; /* Same size as struct sockaddr */ };///Linux /// typedef unsigned short uint16_t; typedef unsigned int uint32_t; typedef uint16_t in_port_t; typedef uint32_t in_addr_t; typedef unsigned short int sa_family_t;struct in_addr {in_addr_t s_addr; }; // sizeof(struct sockaddr) sizeof(struct sockaddr_in) struct sockaddr_in {sa_family_t sin_family; /* 地址族协议: AF_INET */in_port_t sin_port; /* 端口, 2字节- 大端 */struct in_addr sin_addr; /* IP地址, 4字节 - 大端 *//* 填充 8字节 */unsigned char sin_zero[sizeof (struct sockaddr) - sizeof(sin_family) -sizeof (in_port_t) - sizeof (struct in_addr)]; }; 1.5.2.2 大小端转换函数 // 主机字节序 - 网络字节序 u_short htons (u_short hostshort ); u_long htonl ( u_long hostlong);// 网络字节序 - 主机字节序 u_short ntohs (u_short netshort ); u_long ntohl ( u_long netlong);// linux函数, window上没有这两个函数 inet_ntop(); inet_pton();// windows 和 linux 都使用, 只能处理ipv4的ip地址 // 点分十进制IP - 大端整形 unsigned long inet_addr (const char FAR * cp); // windows in_addr_t inet_addr (const char *cp); // linux// 大端整形 - 点分十进制IP // window, linux相同 char* inet_ntoa(struct in_addr in);1.5.2.3 套接字函数 window的api中套接字对应的类型是 SOCKET 类型, linux中是 int 类型, 本质是一样的 // 创建一个套接字 // 返回值: 成功返回套接字, 失败返回INVALID_SOCKET SOCKET socket(int af,int type,int protocal); 参数:- af: 地址族协议- ipv4: AF_INET (windows/linux)- PF_INET (windows)- AF_INET PF_INET- type: 和linux一样- SOCK_STREAM- SOCK_DGRAM- protocal: 一般写0 即可- 在windows上的另一种写法- IPPROTO_TCP, 使用指定的流式协议中的tcp协议- IPPROTO_UDP, 使用指定的报式协议中的udp协议// 关键字: FAR NEAR, 这两个关键字在32/64位机上是没有意义的, 指定的内存的寻址方式 // 套接字绑定本地IP和端口 // 返回值: 成功返回0失败返回SOCKET_ERROR int bind(SOCKET s,const struct sockaddr FAR* name, int namelen);// 设置监听 // 返回值: 成功返回0失败返回SOCKET_ERROR int listen(SOCKET s,int backlog);// 等待并接受客户端连接 // 返回值: 成功返回用于的套接字失败返回INVALID_SOCKET。 SOCKET accept ( SOCKET s, struct sockaddr FAR* addr, int FAR* addrlen );// 连接服务器 // 返回值: 成功返回0失败返回SOCKET_ERROR int connect (SOCKET s,const struct sockaddr FAR* name,int namelen );// 在Qt中connect用户信号槽的连接, 如果要使用windows api 中的 connect 需要在函数名前加:: ::connect(sock, (struct sockaddr*)addr, sizeof(addr));// 接收数据 // 返回值: 成功时返回接收的字节数收到EOF时为0失败时返回SOCKET_ERROR。 // 0 代表对方已经断开了连接 int recv (SOCKET s,char FAR* buf,int len,int flags);// 发送数据 // 返回值: 成功返回传输字节数失败返回SOCKET_ERROR。 int send (SOCKET s,const char FAR * buf, int len,int flags);// 关闭套接字 // 返回值: 成功返回0失败返回SOCKET_ERROR int closesocket (SOCKET s); // 在linux中使用的函数是: int close(int fd);//----------------------- udp 通信函数 ------------------------- // 接收数据 int recvfrom(SOCKET s,char FAR *buf,int len,int flags,struct sockaddr FAR *from,int FAR *fromlen); // 发送数据 int sendto(SOCKET s,const char FAR *buf,int len,int flags,const struct sockaddr FAR *to,int tolen);2. 三次握手,四次挥手 TCP协议是一个安全的、面向连接的、流式传输协议所谓的面向连接就是三次握手 对于我们来说只需要在客户端调用connect()函数三次握手就自动进行了。 通过下图看TCP协议的格式 2.1 tcp协议介绍 在Tcp协议中比较重要的字段有 源端口表示发送端端口号字段长 16 位2个字节 目的端口表示接收端端口号字段长 16 位2个字节 序号sequence number字段长 32 位占4个字节序号的范围为 [04284967296]。 由于TCP是面向字节流的在一个TCP连接中传送的字节流中的每一个字节都按顺序编号首部中的序号字段则是指本报文段所发送的数据的第一个字节的序号这是随机生成的。序号是循环使用的当序号增加到最大值时下一个序号就又回到了0 确认序号acknowledgement number占32位4字节表示收到的下一个报文段的第一个数据字节的序号如果确认序号为N序号为S则表明到序号N-S为止的所有数据字节都已经被正确地接收到了。 8个标志位Flag: CWRCWR 标志与后面的 ECE 标志都用于 IP 首部的 ECN 字段ECE 标志为 1 时则通知对方已将拥塞窗口缩小ECE若其值为 1 则会通知对方从对方到这边的网络有阻塞。在收到数据包的 IP 首部中 ECN 为 1 时将 TCP 首部中的 ECE 设为 1URG该位设为 1表示包中有需要紧急处理的数据对于需要紧急处理的数据与后面的紧急指针有关ACK该位设为 1确认应答的字段有效TCP规定除了最初建立连接时的 SYN 包之外该位必须设为 1PSH该位设为 1表示需要将收到的数据立刻传给上层应用协议若设为 0则先将数据进行缓存RST该位设为 1表示 TCP 连接出现异常必须强制断开连接SYN用于建立连接该位设为 1表示希望建立连接并在其序列号的字段进行序列号初值设定FIN该位设为 1表示今后不再有数据发送希望断开连接。 窗口大小该字段长 16 位表示从确认序号所指位置开始能够接收的数据大小TCP 不允许发送超过该窗口大小的数据。 2.2 三次握手 Tcp连接是双向连接客户端和服务器需要分别向对方发送连接请求并且建立连接三次握手成功之后二者之间的双向连接也就成功建立了。如果要保证三次握手顺利完成必须要满足以下条件 服务器端已经启动并且启动了监听被动接受连接的一端客户端基于服务器端监听的IP和端口向服务器端发起连接请求主动发起连接的一端 三次握手具体过程如下 第一次握手 客户端客户端向服务器端发起连接请求将报文中的SYN字段置为1生成随机序号xseqx服务器端接收客户端发送的请求数据解析tcp协议校验SYN标志位是否为1并得到序号 x 第二次握手 服务器端给客户端回复数据 回复ACK, 将tcp协议ACK对应的标志位设置为1表示同意了客户端建立连接的请求回复了 ackx1, 这是确认序号 x: 客户端生成的随机序号1: 客户端给服务器发送的数据的量, SYN标志位存储到某一个字节中, 因此按照一个字节计算表示客户端给服务器发送的1个字节服务器收到了。 将tcp协议中的SYN对应的标志位设置为 1, 服务器向客户端发起了连接请求服务器端生成了一个随机序号 y, 发送给了客户端 客户端接收回复的数据并解析tcp协议 校验ACK标志位为1表示服务器接收了客户端的连接请求数据校验确认发送给服务器的数据服务器收到了没有计算公式如下 发送的数据的量 使用服务器回复的确认序号 - 客户端生成的随机序号 1x1-x校验SYN标志位为1表示服务器请求和客户端建立连接得到服务器生成的随机序号: y 第三次握手 客户端发送数据给服务器 将tcp协议中ACK标志位设置为1表示同意了服务器的连接请求给服务器回复了一个确认序号 ack y1 y服务器端生成的随机序号1服务器给客户端发送的数据量服务器给客户端发送了ACK和SYN, 都存储在这一个字节中 发送给服务器的序号就是上一次从服务器端收的确认序号因此 seq x1 服务器端接收数据, 并解析tcp协议 查看ACK对应的标志位是否为1, 如果是1代表, 客户端同意了服务器的连接请求数据校验确认发送给客户端的数据客户端收到了没有计算公式如下 给客户端发送的数据量 确认序号 - 服务器生成的随机序号 1y1-y得到客户端发送的序号x1 2.2 TCP四次挥手 四次挥手是断开连接的过程需要双向断开关于由哪一端先断开连接是没有要求的。通信的两端如果想要断开连接就需要调用close()函数当两端都调用了该函数四次挥手也就完成了。 客户端和服务器断开连接 - 单向断开 服务器和客户端断开连接 - 单向断开 进行了两次单向断开双向断开就完成了每进行一次单向断开就会完成两次挥手的动作。 基于上图的例子对四次挥手的具体过程进行阐述实际上那端先断开连接都是允许的 第一次挥手: 主动断开连接的一方发送断开连接的请求 将tcp协议中FIN标志位设置为1表示请求断开连接发送序号x给对端seqx基于这个序号用于客户端数据校验的计算 被动断开连接的一方接收请求数据, 并解析TCP协议 校验FIN标志位是否为1收到了序号 x基于这个数据计算回复的确认序号 ack 的值 第二次挥手: 被动断开连接的一方回复数据 同意了对方断开连接的请求将ACK标志位设置为1回复 ackx1表示成功接受了客户端发送的一个字节数据向客户端发送序号 seqy基于这个序号用于服务器端数据校验的计算 主动断开连接的一方接收回复数据, 并解析TCP协议 校验ACK标志位如果为1表示断开连接的请求对方已经同意了校验 ack确认发送的数据服务器是否收到了发送的数据 ack - x x 1 -x 1 第三次挥手: 被动断开连接的一方将tcp协议中FIN标志位设置为1表示请求断开连接主动断开连接的一方接收请求数据, 并解析TCP协议校验FIN标志位是否为1 第四次挥手: 主动断开连接的一方回复数据 将tcp协议中ACK对应的标志位设置为1表示同意了断开连接的请求acky1表示服务器发送给客户端的一个字节客户端接收到了序号 seqh此时的h应该等于 x1也就是第三次挥手时服务器回复的确认序号ack的值 被动断开连接的一方收到回复的ACK, 此时双向连接双向断开, 通信的两端没有任何关系了 2.3 流量控制 流量控制可以让发送端根据接收端的实际接受能力控制发送的数据量。 它的具体操作是接收端主机向发送端主机通知自己可以接收数据的大小于是发送端会发送不会超过该大小的数据该限制大小即为窗口大小即窗口大小由接收端主机决定。 TCP 首部中专门有一个字段来通知窗口大小接收主机将自己可以接收的缓冲区大小放在该字段中通知发送端。 当接收端的缓冲区面临数据溢出时窗口大小的值也是随之改变设置为一个更小的值通知发送端从而控制数据的发送量这样达到流量的控制。这个控制流程的窗口也可以称作滑动窗口。 此图为一个单向的数据发送: 左侧是数据发送端对应的是发送端的写缓冲区(内存)通过一个环形队列进行数据管理 白色格子: 空闲的内存, 可以写数据粉色的格子: 被写入到内存, 但是还没有被发送出去的数据灰色的格子: 代表已经被发送出去的数据 右侧是数据接收端对应的是接收端的读缓冲区存储发送端发送过来的数据 白色格子空闲的内存, 可以继续接收数据, 滑动窗口的值记录的就是白色的格子的大小 随着接收的数据越来越多, 白色格子越来越少, 滑动窗口的值越来越小如果白色格子没有了, 滑动窗口变为0, 这时候, 发送端就被阻塞了 粉色格子接收的数据但是这个数据还没有从内核中读走使用read() / recv() 粉色格子变少了, 可用空间就变多了, 滑动窗口的值就变大了如果滑动窗口的值从0变为大于0, 接收端又重新有容量接收数据了, 发送端的阻塞自动解除继续发送数据 基于TCP通信的流程图记录了从三次握手 - 数据通信 - 四次挥手的全过程 # fast sender: 客户端 # slow recerver: 服务器 # win: 滑动窗口大小 # mss: maximum segment size, 单条数据的最大长度第1步第一次握手发送连接请求SYN到服务器端 0(0)0表示客户端生成的随机序号(0)表示客户端没有额外给服务器发送数据, 因此数据的量为0win4096: 客户端告诉服务器, 能接收的数据(缓存)的最大量为4kmss1460: 客户端可以处理的单条最大字节数是1460字节 第2步第二次握手 ACK: 服务器同意了客户端的连接请求 SYN: 服务器请求和客户端建立连接 8000(0)8000是服务器端生成的随机序号(0)表示服务器没有额外给客户端发送数据, 因此数据的量为01: 发送给客户端的确认序号 确认序号 客户端生成的随机序号 客户端给服务器发送的数据量(字节数) 101表示客户端给服务器发送的1个字节服务器收到了 win6144: 服务器告诉客户端我能最多缓存 6k数据mss1024: 服务器能处理的单条数据最大长度是 1k 第3步: 第三次握手 ACK: 客户端同意了服务器的连接请求8001: 发送给服务器的确认序号 确认序号 服务器生成的随机序号 服务器给客户端发送的数据量 8001 8000 1客户端告诉服务器, 你给我发送的1个字节的数据我收到了 win4096: 告诉服务器客户端能缓存的最大数据量是4k 第4~9步: 客户端给服务器发送数据 1(1024)1 1-0表示之前一共给服务器发送了1个字节(1024)表示这次要发送的数据量为 1k 1025(1024)10251025-0表示之前一共给服务器发送了1025个字节(1024)表示这次要发送的数据量为 1k 2049(1024)20492049-0表示之前一共给服务器发送了2049个字节(1024)表示这次要发送的数据量为 1k 第9步完成之后服务器的滑动窗口变为0接收数据的缓存被写满了发送端阻塞 第10步: ack6145: 服务器给客户端回复数据6145是确认序号, 代表实际接收的字节数 服务器实际接收的字节数 确认序号 - 客户端生成的随机序号 6145 6145 - 0 win2048服务器告诉客户端我的缓存还有2k也就是还有4k还在缓存中没有被读走 第11步win4096表示滑动窗口变为4k代表还可以接收4k数据还有2k在缓存中 第12步客户端又给服务器发送了1k数据 第13步: 第一次挥手FIN表示客户端主动和服务器断开连接并且发送了1k数据到服务器端 第14步: 第二次挥手回复ACK, 同意断开连接 第15, 16步: 服务器端从读缓冲区中读数据, 第16步数据读完, 滑动窗口变成最大的6k 第17步: 第三次挥手 FIN: 服务器请求和客户端断开连接 8001(0): 服务器一共给客户端发送的字节数 8001 - 8000 1个字节携带的数据量为0FIN不计算在内 ack8194: 服务器收到了客户端的多少个字节: 8194 - 0 8194个字节 第18步: 第四次挥手 ACK: 客户端同意了服务器断开连接的请求8002: 确认序号, 可以计算出服务器给客户端发送了多少数据8002 - 8000 2 个字节 3. TCP状态转换 3.1 TCP状态转换 在TCP进行三次握手或者四次挥手的过程中通信的服务器和客户端内部会发送状态上的变化发生的状态变化在程序中是看不到的这个状态的变化也不需要我们去维护但在某些情况下进行程序的调试会去查看相关的状态信息先来看三次握手过程中的状态转换。 3.1.1 三次握手 在第一次握手之前服务器端必须先启动并且已经开始了监听- 服务器端先调用了 listen() 函数, 开始监听- 服务器启动监听前后的状态变化: 没有状态 --- LISTEN当服务器监听启动之后由客户端发起的三次握手过程中状态转换如下 第一次握手: 客户端调用了connect() 函数状态变化没有状态 - SYN_SENT服务器收到连接请求SYN状态变化LISTEN - SYN_RCVD 第二次握手: 服务器给客户端回复ACK并且请求和客户端建立连接状态无变化依然是 SYN_RCVD客户端接收数据收到了ACK状态变化SYN_SENT - ESTABLISHED 第三次握手: 客户端给服务器回复ACK同意建立连接状态没有变化还是 ESTABLISHED服务器收到了ACK状态变化SYN_RCVD - ESTABLISHED 三次握手完成之后客户端和服务器都变成了同一种状态这种状态叫ESTABLISHED表示双向连接已经建立 可以通信了。在通过过程中正常的通信状态就是 ESTABLISHED。 3.1.2 四次挥手 关于四次挥手对于客户端和服务器哪段先断开连接没有要求根据实际情况处理即可。下面根据上图中的实例描述一下四次挥手过程中TCP的状态转换上图中主动断开连接的一方是客户端 第一次挥手: 客户端调用close() 函数将tcp协议中的FIN设置为1请求和服务器断开连接 状态变化:ESTABLISHED - FIN_WAIT_1 服务器收到断开连接请求状态变化: ESTABLISHED - CLOSE_WAIT 第二次挥手: 服务器回复ACK同意断开连接的请求状态没有变化还是 CLOSE_WAIT客户端收到ACK状态变化FIN_WAIT_1 - FIN_WAIT_2 第三次挥手: 服务器端调用close() 函数发送FIN给客户端请求断开连接状态变化CLOSE_WAIT - LAST_ACK客户端收到FIN状态变化FIN_WAIT_2 - TIME_WAIT 第四次挥手: 客户端回复ACK给服务器状态是没有变化的状态变化TIME_WAIT - 没有状态服务器端收到ACK双向连接断开状态变化LAST_ACK - 无状态(没有了) 3.1.3 状态转换 在下图中同样是描述TCP通信过程中的客户端和服务器端的状态转换 只需要看两条主线红色实线和绿色虚线。关于黑色的实线对应的是一些特殊情况下的状态切换在此不做任何分析。 因为三次握手是由客户端发起的据此分析红色实线表示的客户端的状态绿色虚线表示的是服务器端的状态。 客户端 第一次握手发送SYN没有状态 - SYN_SENT第二次握手收到回复的ACKSYN_SENT - ESTABLISHED主动断开连接第一次挥手发送FIN状态ESTABLISHED - FIN_WAIT_1第二次挥手收到ACK状态FIN_WAIT_1 - FIN_WAIT_2第三次挥手收到FIN状态FIN_WAIT_2 - TIME_WAIT第四次挥手回复ACK等待2倍报文时长之后状态TIME_WAIT - 没有状态 服务器端 启动监听没有状态 - LISTEN第一次握手收到SYN状态LISTEN - SYN_RCVD第三次握手收到ACK状态SYN_RCVD - ESTABLISHED收到断开连接请求第一次挥手状态 ESTABLISHED - CLOSE_WAIT第三次挥手发送FIN请求和客户端断开连接状态CLOSE_WAIT - LAST_ACK第四次挥手收到ACK状态LAST_ACK - 无状态(没有了) 在TCP通信的时候当主动断开连接的一方接收到被动断开连接的一方发送的FIN和最终的ACK后第三次挥手完成连接的主动关闭方必须处于TIME_WAIT状态并持续2MSL(Maximum Segment Lifetime)时间这样就能够让TCP连接的主动关闭方在它发送的ACK丢失的情况下重新发送最终的ACK。 一倍报文寿命(MSL)大概时长为30s因此两倍报文寿命一般在1分钟作用。 主动关闭方重新发送的最终ACK是因为被动关闭方重传了它的FIN。事实上被动关闭方总是重传FIN直到它收到一个最终的ACK。 3.1.4 相关命令 $ netstat 参数 $ netstat -apn | grep 关键字参数: -a (all)显示所有选项-p 显示建立相关链接的程序名-n 拒绝显示别名能显示数字的全部转化成数字。-l 仅列出有在 Listen (监听) 的服务状态-t (tcp)仅显示tcp相关选项-u (udp)仅显示udp相关选项 3.2 半关闭 TCP连接只有一方发送了FIN另一方没有发出FIN包仍然可以在一个方向上正常发送数据这中状态可以称之为半关闭或者半连接。当四次挥手完成两次的时候就相当于实现了半关闭在程序中只需要在某一端直接调用 close() 函数即可。套接字通信默认是双工的也就是双向通信如果进行了半关闭就变成了单工数据只能单向流动了。比如下面的这个例子 服务器端: 调用了close() 函数因此不能发数据只能接收数据关闭了服务器端的写操作现在只能进行读操作 – 变成了读端 客户端: 没有调用close()客户端和服务器的连接还保持着客户端可以给服务器发送数据也可以接收服务器发送的数据 但是服务器已经丧失了发送数据的能力因此客户端也只能发送数据接收不到数据 – 变成了写端 按照上述流程做了半关闭之后从双工变成了单工数据单向流动的方向: 客户端 —– 服务器端。 // 专门处理半关闭的函数 #include sys/socket.h // 可以有选择的关闭读/写, close()函数只能关闭写操作 int shutdown(int sockfd, int how);参数: sockfd: 要操作的文件描述符how: SHUT_RD: 关闭文件描述符对应的读操作SHUT_WR: 关闭文件描述符对应的写操作SHUT_RDWR: 关闭文件描述符对应的读写操作 返回值函数调用成功返回0失败返回-1 3.3 端口复用 在网络通信中一个端口只能被一个进程使用不能多个进程共用同一个端口。我们在进行套接字通信的时候如果按顺序执行如下操作先启动服务器程序再启动客户端程序然后关闭服务器进程再退出客户端进程最后再启动服务器进程就会出如下错误信息bind error: Address already in use # 第二次启动服务器进程 $ ./server bind error: Address already in use$ netstat -apn|grep 9999 (Not all processes could be identified, non-owned process infowill not be shown, you would have to be root to see it all.) tcp 0 0 127.0.0.1:9999 127.0.0.1:50178 TIME_WAIT - 通过netstat查看TCP状态发现上一个服务器进程其实还没有真正退出。 因为服务器进程是主动断开连接的进程, 最后状态变成了 TIME_WAIT状态这个进程会等待2msl(大约1分钟)才会退出如果该进程不退出其绑定的端口就不会释放再次启动新的进程还是使用这个未释放的端口端口被重复使用bind error: Address already in use 如果想要解决上述问题就必须要设置端口复用使用的函数原型如下 // 这个函数是一个多功能函数, 可以设置套接字选项 int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);参数: sockfd用于监听的文件描述符level设置端口复用需要使用 SOL_SOCKET 宏optname要设置什么属性下边的两个宏都可以设置端口复用 SO_REUSEADDRSO_REUSEPORT optval设置是去除端口复用属性还是设置端口复用属性实际应该使用 int 型变量 0不设置1设置 optlenoptval指针指向的内存大小 sizeof(int) 这个函数应该添加到服务器端代码中具体应放在绑定之前设置端口复用 创建监听的套接字设置端口复用绑定设置监听等待并接受客户端连接通信断开连接 参考代码如下: #include stdio.h #include ctype.h #include unistd.h #include stdlib.h #include sys/types.h #include sys/stat.h #include string.h #include arpa/inet.h #include sys/socket.h #include sys/select.h// server int main(int argc, const char* argv[]) {// 创建监听的套接字int lfd socket(AF_INET, SOCK_STREAM, 0);if(lfd -1){perror(socket error);exit(1);}// 绑定struct sockaddr_in serv_addr;memset(serv_addr, 0, sizeof(serv_addr));serv_addr.sin_family AF_INET;serv_addr.sin_port htons(9999);serv_addr.sin_addr.s_addr htonl(INADDR_ANY); // 本地多有的// 127.0.0.1// inet_pton(AF_INET, 127.0.0.1, serv_addr.sin_addr.s_addr);// 设置端口复用int opt 1;setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));// 绑定端口int ret bind(lfd, (struct sockaddr*)serv_addr, sizeof(serv_addr));if(ret -1){perror(bind error);exit(1);}// 监听ret listen(lfd, 64);if(ret -1){perror(listen error);exit(1);}fd_set reads, tmp;FD_ZERO(reads);FD_SET(lfd, reads);int maxfd lfd;while(1){tmp reads;int ret select(maxfd1, tmp, NULL, NULL, NULL);if(ret -1){perror(select);exit(0);}if(FD_ISSET(lfd, tmp)){int cfd accept(lfd, NULL, NULL);FD_SET(cfd, reads);maxfd cfd maxfd ? cfd : maxfd;}for(int ilfd1; imaxfd; i){if(FD_ISSET(i, tmp)){char buf[1024];int len read(i, buf, sizeof(buf));if(len 0){printf(client say: %s\n, buf);write(i, buf, len);}else if(len 0){printf(客户端断开了连接\n);FD_CLR(i, reads);close(i);}else{perror(read);exit(0);}}}}return 0; }4. 服务器并发 4.1 单线程/进程 在TCP通信过程中服务器端启动之后可以同时和多个客户端建立连接并进行网络通信但是在介绍TCP通信流程的时候提供的服务器代码却不能完成这样的需求 看之前的服务器代码的处理思路分析弊端 // server.c #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建监听的套接字int lfd socket(AF_INET, SOCK_STREAM, 0);// 2. 将socket()返回值和本地的IP端口绑定到一起struct sockaddr_in addr;addr.sin_family AF_INET;addr.sin_port htons(10000); // 大端端口// INADDR_ANY代表本机的所有IP, 假设有三个网卡就有三个IP地址// 这个宏可以代表任意一个IP地址addr.sin_addr.s_addr INADDR_ANY; // 这个宏的值为0 0.0.0.0int ret bind(lfd, (struct sockaddr*)addr, sizeof(addr));// 3. 设置监听ret listen(lfd, 128);// 4. 阻塞等待并接受客户端连接struct sockaddr_in cliaddr;int clilen sizeof(cliaddr);int cfd accept(lfd, (struct sockaddr*)cliaddr, clilen);// 5. 和客户端通信while(1){// 接收数据char buf[1024];memset(buf, 0, sizeof(buf));int len read(cfd, buf, sizeof(buf));if(len 0){printf(客户端say: %s\n, buf);write(cfd, buf, len);}else if(len 0){printf(客户端断开了连接...\n);break;}else{perror(read);break;}}close(cfd);close(lfd);return 0; }在上面的代码中用到了三个会引起程序阻塞的函数分别是 accept()如果服务器端没有新客户端连接阻塞当前进程/线程如果检测到新连接解除阻塞建立连接read()如果通信的套接字对应的读缓冲区没有数据阻塞当前进程/线程检测到数据解除阻塞接收数据write()如果通信的套接字写缓冲区被写满了阻塞当前进程/线程比较少见 如果需要和发起新的连接请求的客户端建立连接那么就必须在服务器端通过一个循环调用accept()函数另外已经和服务器建立连接的客户端需要和服务器通信发送数据时的阻塞可以忽略当接收不到数据时程序也会被阻塞这时候就会非常矛盾被accept()阻塞就无法通信被read()阻塞就无法和客户端建立新连接。 基于上述处理方式在单线程/单进程场景下服务器是无法处理多连接的 解决方案也有很多常用的有三种 使用多线程实现使用多进程实现使用IO多路转接复用实现使用IO多路转接 多线程实现 4.2 多进程并发 如果要编写多进程版的并发服务器程序首先要考虑创建出的多个进程都是什么角色这样就可以在程序中对号入座了。在Tcp服务器端一共有两个角色分别是监听和通信监听是一个持续的动作如果有新连接就建立连接如果没有新连接就阻塞。关于通信是需要和多个客户端同时进行的因此需要多个进程这样才能达到互不影响的效果。进程也有两大类父进程和子进程通过分析我们可以这样分配进程 父进程 负责监听处理客户端的连接请求也就是在父进程中循环调用accept()函数创建子进程建立一个新的连接就创建一个新的子进程让这个子进程和对应的客户端通信回收子进程资源子进程退出回收其内核PCB资源防止出现僵尸进程 子进程负责通信基于父进程建立新连接之后得到的文件描述符和对应的客户端完成数据的接收和发送。 发送数据send() / write()接收数据recv() / read() 在多进程版的服务器端程序中多个进程是有血缘关系对应有血缘关系的进程来说还需要想明白他们有哪些资源是可以被继承的哪些资源是独占的以及一些其他细节 子进程是父进程的拷贝在子进程的内核区PCB中文件描述符也是可以被拷贝的因此在父进程可以使用的文件描述符在子进程中也有一份并且可以使用它们做和父进程一样的事情。 父子进程有用各自的独立的虚拟地址空间因此所有的资源都是独占的 为了节省系统资源对于只有在父进程才能用到的资源可以在子进程中将其释放掉父进程亦如此。 由于需要在父进程中做accept()操作并且要释放子进程资源如果想要更高效一下可以使用信号的方式处理 多进程版并发TCP服务器示例代码如下 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.h #include signal.h #include sys/wait.h #include errno.h// 信号处理函数 void callback(int num) {while(1){pid_t pid waitpid(-1, NULL, WNOHANG);if(pid 0){printf(子进程正在运行, 或者子进程被回收完毕了\n);break;}printf(child die, pid %d\n, pid);} }int childWork(int cfd); int main() {// 1. 创建监听的套接字int lfd socket(AF_INET, SOCK_STREAM, 0);if(lfd -1){perror(socket);exit(0);}// 2. 将socket()返回值和本地的IP端口绑定到一起struct sockaddr_in addr;addr.sin_family AF_INET;addr.sin_port htons(10000); // 大端端口// INADDR_ANY代表本机的所有IP, 假设有三个网卡就有三个IP地址// 这个宏可以代表任意一个IP地址// 这个宏一般用于本地的绑定操作addr.sin_addr.s_addr INADDR_ANY; // 这个宏的值为0 0.0.0.0// inet_pton(AF_INET, 192.168.237.131, addr.sin_addr.s_addr);int ret bind(lfd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(bind);exit(0);}// 3. 设置监听ret listen(lfd, 128);if(ret -1){perror(listen);exit(0);}// 注册信号的捕捉struct sigaction act;act.sa_flags 0;act.sa_handler callback;sigemptyset(act.sa_mask);sigaction(SIGCHLD, act, NULL);// 接受多个客户端连接, 对需要循环调用 acceptwhile(1){// 4. 阻塞等待并接受客户端连接struct sockaddr_in cliaddr;int clilen sizeof(cliaddr);int cfd accept(lfd, (struct sockaddr*)cliaddr, clilen);if(cfd -1){if(errno EINTR){// accept调用被信号中断了, 解除阻塞, 返回了-1// 重新调用一次acceptcontinue;}perror(accept);exit(0);}// 打印客户端的地址信息char ip[24] {0};printf(客户端的IP地址: %s, 端口: %d\n,inet_ntop(AF_INET, cliaddr.sin_addr.s_addr, ip, sizeof(ip)),ntohs(cliaddr.sin_port));// 新的连接已经建立了, 创建子进程, 让子进程和这个客户端通信pid_t pid fork();if(pid 0){// 子进程 - 和客户端通信// 通信的文件描述符cfd被拷贝到子进程中// 子进程不负责监听close(lfd);while(1){int ret childWork(cfd);if(ret 0){break;}}// 退出子进程close(cfd);exit(0);}else if(pid 0){// 父进程不和客户端通信close(cfd);}}return 0; }// 5. 和客户端通信 int childWork(int cfd) {// 接收数据char buf[1024];memset(buf, 0, sizeof(buf));int len read(cfd, buf, sizeof(buf));if(len 0){printf(客户端say: %s\n, buf);write(cfd, buf, len);}else if(len 0){printf(客户端断开了连接...\n);}else{perror(read);}return len; }在上面的示例代码中父子进程中分别关掉了用不到的文件描述符父进程不需要通信子进程也不需要监听。如果客户端主动断开连接那么服务器端负责和客户端通信的子进程也就退出了子进程退出之后会给父进程发送一个叫做SIGCHLD的信号在父进程中通过sigaction()函数捕捉了该信号通过回调函数callback()中的waitpid()对退出的子进程进行了资源回收。 还有一个细节这是父进程的处理代码 int cfd accept(lfd, (struct sockaddr*)cliaddr, clilen); while(1) {int cfd accept(lfd, (struct sockaddr*)cliaddr, clilen);if(cfd -1){if(errno EINTR){// accept调用被信号中断了, 解除阻塞, 返回了-1// 重新调用一次acceptcontinue;}perror(accept);exit(0);}}如果父进程调用accept() 函数没有检测到新的客户端连接父进程就阻塞在这儿了这时候有子进程退出了发送信号给父进程父进程就捕捉到了这个信号SIGCHLD 由于信号的优先级很高会打断代码正常的执行流程因此父进程的阻塞被中断转而去处理这个信号对应的函数callback()处理完毕 再次回到accept()位置但是这是已经无法阻塞了函数直接返回-1此时函数调用失败错误描述为accept: Interrupted system call对应的错误号为EINTR由于代码是被信号中断导致的错误所以可以在程序中对这个错误号进行判断让父进程重新调用accept()继续阻塞或者接受客户端的新连接。 4.3 多线程并发 编写多线程版的并发服务器程序和多进程思路差不多考虑明白了对号入座即可。多线程中的线程有两大类主线程父线程和子线程他们分别要在服务器端处理监听和通信流程。 主线程 负责监听处理客户端的连接请求也就是在父进程中循环调用accept()函数创建子线程建立一个新的连接就创建一个新的子进程让这个子进程和对应的客户端通信回收子线程资源由于回收需要调用阻塞函数这样就会影响accept()直接做线程分离即可。 子线程负责通信基于主线程建立新连接之后得到的文件描述符和对应的客户端完成数据的接收和发送。 发送数据send() / write()接收数据recv() / read() 在多线程版的服务器端程序中多个线程共用同一个地址空间有些数据是共享的有些数据的独占的分析其中的一些细节 同一地址空间中的多个线程的栈空间是独占的多个线程共享全局数据区堆区以及内核区的文件描述符等资源因此需要注意数据覆盖问题并且在多个线程访问共享资源的时候还需要进行线程同步。 多线程版Tcp服务器示例代码如下 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.h #include pthread.hstruct SockInfo {int fd; // 通信pthread_t tid; // 线程IDstruct sockaddr_in addr; // 地址信息 };struct SockInfo infos[128];void* working(void* arg) {while(1){struct SockInfo* info (struct SockInfo*)arg;// 接收数据char buf[1024];int ret read(info-fd, buf, sizeof(buf));if(ret 0){printf(客户端已经关闭连接...\n);info-fd -1;break;}else if(ret -1){printf(接收数据失败...\n);info-fd -1;break;}else{write(info-fd, buf, strlen(buf)1);}}return NULL; }int main() {// 1. 创建用于监听的套接字int fd socket(AF_INET, SOCK_STREAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 绑定struct sockaddr_in addr;addr.sin_family AF_INET; // ipv4addr.sin_port htons(8989); // 字节序应该是网络字节序addr.sin_addr.s_addr INADDR_ANY; // 0, 获取IP的操作交给了内核int ret bind(fd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(bind);exit(0);}// 3.设置监听ret listen(fd, 100);if(ret -1){perror(listen);exit(0);}// 4. 等待, 接受连接请求int len sizeof(struct sockaddr);// 数据初始化int max sizeof(infos) / sizeof(infos[0]);for(int i0; imax; i){bzero(infos[i], sizeof(infos[i]));infos[i].fd -1;infos[i].tid -1;}// 父进程监听, 子进程通信while(1){// 创建子线程struct SockInfo* pinfo;for(int i0; imax; i){if(infos[i].fd -1){pinfo infos[i];break;}if(i max-1){sleep(1);i--;}}int connfd accept(fd, (struct sockaddr*)pinfo-addr, len);printf(parent thread, connfd: %d\n, connfd);if(connfd -1){perror(accept);exit(0);}pinfo-fd connfd;pthread_create(pinfo-tid, NULL, working, pinfo);pthread_detach(pinfo-tid);}// 释放资源close(fd); // 监听return 0; }在编写多线程版并发服务器代码的时候需要注意父子线程共用同一个地址空间中的文件描述符因此每当在主线程中建立一个新的连接都需要将得到文件描述符值保存起来不能在同一变量上进行覆盖这样做丢失了之前的文件描述符值也就不知道怎么和客户端通信了。 在上面示例代码中是将成功建立连接之后得到的用于通信的文件描述符值保存到了一个全局数组中每个子线程需要和不同的客户端通信需要的文件描述符值也就不一样只要保证存储每个有效文件描述符值的变量对应不同的内存地址在使用的时候就不会发生数据覆盖的现象造成通信数据的混乱了。 5. TCP数据粘包的处理 5.1 TCP 在前面介绍套接字通信的时候说到了TCP是传输层协议它是一个面向连接的、安全的、流式传输协议。因为数据的传输是基于流的所以发送端和接收端每次处理的数据的量处理数据的频率可以不是对等的可以按照自身需求来进行决策。 TCP协议是优势非常明显但假设我们有需求 客户端和服务器之间要进行基于TCP的套接字通信 通信过程中客户端会每次会不定期给服务器发送一个不定长度的有特定含义的字符串。通信的服务器端每次都需要接收到客户端这个不定长度的字符串并对其进行解析 根据上面的描述服务器在接收数据的时候有如下几种情况 一次接收到了客户端发送过来的一个完整的数据包一次接收到了客户端发送过来的N个数据包由于每个包的长度不定无法将各个数据包拆开一次接收到了一个或者N个数据包 下一个数据包的一部分无法将数据包拆开一次收到了半个数据包下一次接收数据的时候收到了剩下的一部分下个数据包的一部分另外还有一些不可抗拒的因素比如客户端和服务器端的网速不一样发送和接收的数据量也会不一致 对于以上描述的现象很多时候我们将其称之为TCP的粘包问题 本身TCP就是面向连接的流式传输协议特性如此我们却说是TCP这个协议出了问题这是使用者的无知。多个数据包粘连到一起无法拆分是需求过于复杂造成的是我们的问题而不是协议的问题。 服务器端如果想保证每次都能接收到客户端发送过来的这个不定长度的数据包如何解决 使用标准的应用层协议比如http、https来封装要传输的不定长的数据包在每条数据的尾部添加特殊字符, 如果遇到特殊字符, 代表当条数据接收完毕了 有缺陷: 效率低, 需要一个字节一个字节接收, 接收一个字节判断一次 在发送数据块之前, 在数据块最前边添加一个固定大小的数据头, 这时候数据由两部分组成数据头数据块 数据头存储当前数据包的总字节数接收端先接收数据头然后在根据数据头接收对应大小的字节数据块当前数据包的内容 5.2 解决方案 如果使用TCP进行套接字通信如果发送的数据包粘连到一起导致接收端无法解析我们通常使用添加包头的方式解决这个问题。 关于数据包的包头大小可以根据自己的实际需求进行设定这里没特殊需求因此规定包头的固定大小为4个字节用于存储当前数据块的总字节数。 5.2.1 发送端 对于发送端来说数据的发送分为4步 根据待发送的数据长度N动态申请一块固定大小的内存N44是包头占用的字节数将待发送数据的总长度写入申请的内存的前四字节中此处需要将其转换为网络字节序(大端)将待发送的数据拷贝到包头后边的地址空间中将完整的数据包发送出去字符串没有字节序问题释放申请的堆内存。 由于发送端每次都需要将这个数据包完整的发送出去因此可以设计一个发送函数如果当前数据包中的数据没有发送完就让它一直发送处理代码如下 /* 函数描述: 发送指定的字节数 函数参数:- fd: 通信的文件描述符(套接字)- msg: 待发送的原始数据- size: 待发送的原始数据的总字节数 函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1 */ int writen(int fd, const char* msg, int size) {const char* buf msg;int count size;while (count 0){int len send(fd, buf, count, 0);if (len -1){close(fd);return -1;}else if (len 0){continue;}buf len;count - len;}return size; }有了这个功能函数之后就可以发送带有包头的数据块了具体处理动作如下 /* 函数描述: 发送带有数据头的数据包 函数参数:- cfd: 通信的文件描述符(套接字)- msg: 待发送的原始数据- len: 待发送的原始数据的总字节数 函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1 */ int sendMsg(int cfd, char* msg, int len) {if(msg NULL || len 0 || cfd 0){return -1;}// 申请内存空间: 数据长度 包头4字节(存储数据长度)char* data (char*)malloc(len4);int bigLen htonl(len);memcpy(data, bigLen, 4);memcpy(data4, msg, len);// 发送数据int ret writen(cfd, data, len4);// 释放内存free(data);return ret; }字符串没有字节序问题但是数据头不是字符串是整形因此需从主机字节序转换为网络字节序再发送。 5.2.2 接收端 了解了套接字的发送端如何发送数据接收端的处理步骤也就清晰了具体过程如下 首先接收4字节数据并将其从网络字节序转换为主机字节序这样就得到了即将要接收的数据的总长度根据得到的长度申请固定大小的堆内存用于存储待接收的数据根据得到的数据块长度接收固定数目的数据保存到申请的堆内存中处理接收的数据释放存储数据的堆内存 从数据包头解析出要接收的数据长度之后还需要将这个数据块完整的接收到本地才能进行后续的数据处理因此需要编写一个接收数据的功能函数保证能够得到一个完整的数据包数据 处理函数实现如下 /* 函数描述: 接收指定的字节数 函数参数:- fd: 通信的文件描述符(套接字)- buf: 存储待接收数据的内存的起始地址- size: 指定要接收的字节数 函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1 */ int readn(int fd, char* buf, int size) {char* pt buf;int count size;while (count 0){int len recv(fd, pt, count, 0);if (len -1){return -1;}else if (len 0){return size - count;}pt len;count - len;}return size; }这个函数搞定之后就可以轻松地接收带包头的数据块了接收函数实现如下 /* 函数描述: 接收带数据头的数据包 函数参数:- cfd: 通信的文件描述符(套接字)- msg: 一级指针的地址函数内部会给这个指针分配内存用于存储待接收的数据这块内存需要使用者释放 函数返回值: 函数调用成功返回接收的字节数, 发送失败返回-1 */ int recvMsg(int cfd, char** msg) {// 接收数据// 1. 读数据头int len 0;readn(cfd, (char*)len, 4);len ntohl(len);printf(数据块大小: %d\n, len);// 根据读出的长度分配内存1 - 这个字节存储\0char *buf (char*)malloc(len1);int ret readn(cfd, buf, len);if(ret ! len){close(cfd);free(buf);return -1;}buf[len] \0;*msg buf;return ret; }这样在进行套接字通信的时候通过调用封装的sendMsg()和recvMsg()就可以发送和接收带数据头的数据包了而且完美地解决了粘包的问题。 6. 套接字通信类的封装 在掌握了基于TCP的套接字通信流程之后为了方便使用提高编码效率可以对通信操作进行封装先基于C语言进行面向过程的函数封装再基于C进行面向对象的类封装。 6.1 基于C语言的封装 基于TCP的套接字通信分为两部分服务器端通信和客户端通信。只要掌握了通信流程封装出对应的功能函数也就不在话下了回顾一下通信流程 服务器端 创建用于监听的套接字将用于监听的套接字和本地的IP以及端口进行绑定启动监听等待并接受新的客户端连接连接建立得到用于通信的套接字和客户端的IP、端口信息使用得到的通信的套接字和客户端通信接收和发送数据通信结束关闭套接字监听 通信 客户端 创建用于通信的套接字使用服务器端绑定的IP和端口连接服务器使用通信的套接字和服务器通信发送和接收数据通信结束关闭套接字通信 6.1.1 函数声明 通过通信流程可以看出服务器和客户端有些操作步骤是相同的因此封装的功能函数是可以共用的相关的通信函数声明如下 /// 服务器 /// /// int bindSocket(int lfd, unsigned short port); int setListen(int lfd); int acceptConn(int lfd, struct sockaddr_in *addr);/// 客户端 /// /// int connectToHost(int fd, const char* ip, unsigned short port);/// / 共用 /// int createSocket(); int sendMsg(int fd, const char* msg); int recvMsg(int fd, char* msg, int size); int closeSocket(int fd); int readn(int fd, char* buf, int size); int writen(int fd, const char* msg, int size);关于函数readn()和writen()的作用 参考 TCP数据粘包处理 6.1.2 函数定义 // 创建监套接字 int createSocket() {int fd socket(AF_INET, SOCK_STREAM, 0);if(fd -1){perror(socket);return -1;}printf(套接字创建成功, fd%d\n, fd);return fd; }// 绑定本地的IP和端口 int bindSocket(int lfd, unsigned short port) {struct sockaddr_in saddr;saddr.sin_family AF_INET;saddr.sin_port htons(port);saddr.sin_addr.s_addr INADDR_ANY; // 0 0.0.0.0int ret bind(lfd, (struct sockaddr*)saddr, sizeof(saddr));if(ret -1){perror(bind);return -1;}printf(套接字绑定成功, ip: %s, port: %d\n,inet_ntoa(saddr.sin_addr), port);return ret; }// 设置监听 int setListen(int lfd) {int ret listen(lfd, 128);if(ret -1){perror(listen);return -1;}printf(设置监听成功...\n);return ret; }// 阻塞并等待客户端的连接 int acceptConn(int lfd, struct sockaddr_in *addr) {int cfd -1;if(addr NULL){cfd accept(lfd, NULL, NULL);}else{int addrlen sizeof(struct sockaddr_in);cfd accept(lfd, (struct sockaddr*)addr, addrlen);}if(cfd -1){perror(accept);return -1;} printf(成功和客户端建立连接...\n);return cfd; }// 接收数据 int recvMsg(int cfd, char** msg) {if(msg NULL || cfd 0){return -1;}// 接收数据// 1. 读数据头int len 0;readn(cfd, (char*)len, 4);len ntohl(len);printf(数据块大小: %d\n, len);// 根据读出的长度分配内存char *buf (char*)malloc(len1);int ret readn(cfd, buf, len);if(ret ! len){return -1;}buf[len] \0;*msg buf;return ret; }// 发送数据 int sendMsg(int cfd, char* msg, int len) {if(msg NULL || len 0){return -1;}// 申请内存空间: 数据长度 包头4字节(存储数据长度)char* data (char*)malloc(len4);int bigLen htonl(len);memcpy(data, bigLen, 4);memcpy(data4, msg, len);// 发送数据int ret writen(cfd, data, len4);return ret; }// 连接服务器 int connectToHost(int fd, const char* ip, unsigned short port) {// 2. 连接服务器IP portstruct sockaddr_in saddr;saddr.sin_family AF_INET;saddr.sin_port htons(port);inet_pton(AF_INET, ip, saddr.sin_addr.s_addr);int ret connect(fd, (struct sockaddr*)saddr, sizeof(saddr));if(ret -1){perror(connect);return -1;}printf(成功和服务器建立连接...\n);return ret; }// 关闭套接字 int closeSocket(int fd) {int ret close(fd);if(ret -1){perror(close);}return ret; }// 接收指定的字节数 // 函数调用成功返回 size int readn(int fd, char* buf, int size) {int nread 0;int left size;char* p buf;while(left 0){if((nread read(fd, p, left)) 0){p nread;left - nread;}else if(nread -1){return -1;}}return size; }// 发送指定的字节数 // 函数调用成功返回 size int writen(int fd, const char* msg, int size) {int left size;int nwrite 0;const char* p msg;while(left 0){if((nwrite write(fd, msg, left)) 0){p nwrite;left - nwrite;}else if(nwrite -1){return -1;}}return size; }6.2 基于C的封装 编写C程序应当遵循面向对象三要素封装、继承、多态。简单地说就是封装之后的类可以隐藏掉某些属性使操作更简单并且类的功能要单一如果要代码重用可以进行类之间的继承如果要让函数的使用更加灵活可以使用多态。 因此我们需要封装两个类客户端类和服务器端的类。 6.2.1 版本1 根据面向对象的思想整个通信过程不管是监听还是通信的套接字都是可以封装到类的内部并且将其隐藏掉这样相关操作函数的参数也就随之减少了使用者用起来也更简便。 6.2.1.1 客户端 class TcpClient { public:TcpClient();~TcpClient();// int connectToHost(int fd, const char* ip, unsigned short port);int connectToHost(string ip, unsigned short port);// int sendMsg(int fd, const char* msg);int sendMsg(string msg);// int recvMsg(int fd, char* msg, int size);string recvMsg();// int createSocket();// int closeSocket(int fd);private:// int readn(int fd, char* buf, int size);int readn(char* buf, int size);// int writen(int fd, const char* msg, int size);int writen(const char* msg, int size);private:int cfd; // 通信的套接字 };通过对客户端的操作进行封装我们可以看到有如下的变化 文件描述被隐藏了封装到了类的内部已经无法进行外部访问功能函数的参数变少了因为类成员函数可以直接使用类内部的成员变量。创建和销毁套接字的函数去掉了这两个操作可以分别放到构造和析构函数内部进行处理。在C中可以适当的将char* 替换为 string 类这样操作字符串就更简便一些。 6.2.1.2 服务器端 class TcpServer { public:TcpServer();~TcpServer();// int bindSocket(int lfd, unsigned short port) int setListen(int lfd)int setListen(unsigned short port);// int acceptConn(int lfd, struct sockaddr_in *addr);int acceptConn(struct sockaddr_in *addr);// int sendMsg(int fd, const char* msg);int sendMsg(string msg);// int recvMsg(int fd, char* msg, int size);string recvMsg();// int createSocket();// int closeSocket(int fd);private:// int readn(int fd, char* buf, int size);int readn(char* buf, int size);// int writen(int fd, const char* msg, int size);int writen(const char* msg, int size);private:int lfd; // 监听的套接字int cfd; // 通信的套接字 };通过对服务器端的操作进行封装我们可以看到这个类和客户端的类结构以及封装思路是差不多的并且两个类的内部有些操作的重叠的接收和发送通信数据的函数recvMsg()、sendMsg()以及内部函数readn()、writen()。 不仅如此服务器端的类设计成这样样子是有缺陷的服务器端一般需要和多个客户端建立连接因此通信的套接字就需要有N个但是在上面封装的类里边只有一个。 如何解决服务器和客户端的代码冗余和服务器不能跟多客户端通信的问题 减负。可以将服务器的通信功能去掉只留下监听并建立新连接一个功能。将客户端类变成一个专门用于套接字通信的类即可。服务器端整个流程使用服务器类通信类来处理客户端整个流程通过通信的类来处理。 6.2.2 版本2 根据对第一个版本的分析可以对以上代码做如下修改 6.2.2.1 通信类 套接字通信类既可以在客户端使用也可以在服务器端使用职责是接收和发送数据包。 类声明 class TcpSocket { public:TcpSocket();TcpSocket(int socket);~TcpSocket();int connectToHost(string ip, unsigned short port);int sendMsg(string msg);string recvMsg();private:int readn(char* buf, int size);int writen(const char* msg, int size);private:int m_fd; // 通信的套接字 };类定义 TcpSocket::TcpSocket() {m_fd socket(AF_INET, SOCK_STREAM, 0); }TcpSocket::TcpSocket(int socket) {m_fd socket; }TcpSocket::~TcpSocket() {if (m_fd 0){close(m_fd);} }int TcpSocket::connectToHost(string ip, unsigned short port) {// 连接服务器IP portstruct sockaddr_in saddr;saddr.sin_family AF_INET;saddr.sin_port htons(port);inet_pton(AF_INET, ip.data(), saddr.sin_addr.s_addr);int ret connect(m_fd, (struct sockaddr*)saddr, sizeof(saddr));if (ret -1){perror(connect);return -1;}cout 成功和服务器建立连接... endl;return ret; }int TcpSocket::sendMsg(string msg) {// 申请内存空间: 数据长度 包头4字节(存储数据长度)char* data new char[msg.size() 4];int bigLen htonl(msg.size());memcpy(data, bigLen, 4);memcpy(data 4, msg.data(), msg.size());// 发送数据int ret writen(data, msg.size() 4);delete[]data;return ret; }string TcpSocket::recvMsg() {// 接收数据// 1. 读数据头int len 0;readn((char*)len, 4);len ntohl(len);cout 数据块大小: len endl;// 根据读出的长度分配内存char* buf new char[len 1];int ret readn(buf, len);if (ret ! len){return string();}buf[len] \0;string retStr(buf);delete[]buf;return retStr; }int TcpSocket::readn(char* buf, int size) {int nread 0;int left size;char* p buf;while (left 0){if ((nread read(m_fd, p, left)) 0){p nread;left - nread;}else if (nread -1){return -1;}}return size; }int TcpSocket::writen(const char* msg, int size) {int left size;int nwrite 0;const char* p msg;while (left 0){if ((nwrite write(m_fd, msg, left)) 0){p nwrite;left - nwrite;}else if (nwrite -1){return -1;}}return size; }在第二个版本的套接字通信类中一共有两个构造函数 TcpSocket::TcpSocket() {m_fd socket(AF_INET, SOCK_STREAM, 0); }TcpSocket::TcpSocket(int socket) {m_fd socket; }无参构造一般在客户端使用通过这个套接字对象再和服务器进行连接之后就可以通信了有参构造主要在服务器端使用当服务器端得到了一个用于通信的套接字对象之后就可以基于这个套接字直接通信因此不需要再次进行连接操作。 6.2.2.2 服务器类 服务器类主要用于套接字通信的服务器端并且没有通信能力当服务器和客户端的新连接建立之后需要通过TcpSocket类的带参构造将通信的描述符包装成一个通信对象这样就可以使用这个对象和客户端通信了。 类声明 class TcpServer { public:TcpServer();~TcpServer();int setListen(unsigned short port);TcpSocket* acceptConn(struct sockaddr_in* addr nullptr);private:int m_fd; // 监听的套接字 };类定义 TcpServer::TcpServer() {m_fd socket(AF_INET, SOCK_STREAM, 0); }TcpServer::~TcpServer() {close(m_fd); }int TcpServer::setListen(unsigned short port) {struct sockaddr_in saddr;saddr.sin_family AF_INET;saddr.sin_port htons(port);saddr.sin_addr.s_addr INADDR_ANY; // 0 0.0.0.0int ret bind(m_fd, (struct sockaddr*)saddr, sizeof(saddr));if (ret -1){perror(bind);return -1;}cout 套接字绑定成功, ip: inet_ntoa(saddr.sin_addr) , port: port endl;ret listen(m_fd, 128);if (ret -1){perror(listen);return -1;}cout 设置监听成功... endl;return ret; }TcpSocket* TcpServer::acceptConn(sockaddr_in* addr) {if (addr NULL){return nullptr;}socklen_t addrlen sizeof(struct sockaddr_in);int cfd accept(m_fd, (struct sockaddr*)addr, addrlen);if (cfd -1){perror(accept);return nullptr;}printf(成功和客户端建立连接...\n);return new TcpSocket(cfd); }通过调整可以发现套接字服务器类功能更加单一了这样设计即解决了代码冗余问题还能使这两个类更容易维护。 6.3 测试代码 6.3.1 客户端 int main() {// 1. 创建通信的套接字TcpSocket tcp;// 2. 连接服务器IP portint ret tcp.connectToHost(192.168.237.131, 10000);if (ret -1){return -1;}// 3. 通信int fd1 open(english.txt, O_RDONLY);int length 0;char tmp[100];memset(tmp, 0, sizeof(tmp));while ((length read(fd1, tmp, sizeof(tmp))) 0){// 发送数据tcp.sendMsg(string(tmp, length));cout send Msg: endl;cout tmp endl endl endl;memset(tmp, 0, sizeof(tmp));// 接收数据usleep(300);}sleep(10);return 0; }6.3.2 服务器端 struct SockInfo {TcpServer* s;TcpSocket* tcp;struct sockaddr_in addr; };void* working(void* arg) {struct SockInfo* pinfo static_caststruct SockInfo*(arg);// 连接建立成功, 打印客户端的IP和端口信息char ip[32];printf(客户端的IP: %s, 端口: %d\n,inet_ntop(AF_INET, pinfo-addr.sin_addr.s_addr, ip, sizeof(ip)),ntohs(pinfo-addr.sin_port));// 5. 通信while (1){printf(接收数据: .....\n);string msg pinfo-tcp-recvMsg();if (!msg.empty()){cout msg endl endl endl;}else{break;}}delete pinfo-tcp;delete pinfo;return nullptr; }int main() {// 1. 创建监听的套接字TcpServer s;// 2. 绑定本地的IP port并设置监听s.setListen(10000);// 3. 阻塞并等待客户端的连接while (1){SockInfo* info new SockInfo;TcpSocket* tcp s.acceptConn(info-addr);if (tcp nullptr){cout 重试.... endl;continue;}// 创建子线程pthread_t tid;info-s s;info-tcp tcp;pthread_create(tid, NULL, working, info);pthread_detach(tid);}return 0; }7. IO多路转接复用之select 7.1 IO多路转接(复用) IO多路转接也称为IO多路复用它是一种网络通信的手段机制 通过这种方式可以同时监测多个文件描述符并且这个过程是阻塞的一旦检测到有文件描述符就绪 可以读数据或者可以写数据程序的阻塞就会被解除之后就可以基于这些一个或多个就绪的文件描述符进行通信了。 通过这种方式在单线程/进程的场景下也可以在服务器端实现并发。 常见的IO多路转接方式有select、poll、epoll。 下面先对多线程/多进程并发和IO多路转接的并发处理流程进行对比服务器端 多线程/多进程并发 主线程/父进程调用 accept()监测客户端连接请求 如果没有新的客户端的连接请求当前线程/进程会阻塞如果有新的客户端连接请求解除阻塞建立连接 子线程/子进程和建立连接的客户端通信 调用 read() / recv()接收客户端发送的通信数据如果没有通信数据当前线程/进程会阻塞数据到达之后阻塞自动解除调用 write() / send() 给客户端发送数据如果写缓冲区已满当前线程/进程会阻塞否则将待发送数据写入写缓冲区中 IO多路转接并发 使用IO多路转接函数委托内核检测服务器端所有的文件描述符通信和监听两类这个检测过程会导致进程/线程的阻塞如果检测到已就绪的文件描述符阻塞解除并将这些已就绪的文件描述符传出根据类型对传出的所有已就绪文件描述符进行判断并做出不同的处理 监听的文件描述符和客户端建立连接 此时调用accept()是不会导致程序阻塞的因为监听的文件描述符是已就绪的有新请求 通信的文件描述符调用通信函数和已建立连接的客户端通信 调用 read() / recv() 不会阻塞程序因为通信的文件描述符是就绪的读缓冲区内已有数据调用 write() / send() 不会阻塞程序因为通信的文件描述符是就绪的写缓冲区不满可以往里面写数据 对这些文件描述符继续进行下一轮的检测循环往复。。。 与多进程和多线程技术相比I/O多路复用技术的最大优势是系统开销小系统不必创建进程/线程也不必维护这些进程/线程从而大大减小了系统的开销。 7.2 select 7.2.1 函数原型 使用select这种IO多路转接方式需要调用一个同名函数select 这个函数是跨平台的Linux、Mac、Windows都是支持的。通过调用这个函数可以委托内核帮助我们检测若干个文件描述符的状态其实就是检测这些文件描述符对应的读写缓冲区的状态 读缓冲区检测里边有没有数据如果有数据该缓冲区对应的文件描述符就绪写缓冲区检测写缓冲区是否可以写(有没有容量)如果有容量可以写缓冲区对应的文件描述符就绪读写异常检测读写缓冲区是否有异常如果有该缓冲区对应的文件描述符就绪 委托检测的文件描述符被遍历检测完毕之后已就绪的这些满足条件的文件描述符会通过select()的参数分3个集合传出,我们得到这几个集合之后就可以分情况依次处理了。 下面来看一下这个函数的函数原型 #include sys/select.h struct timeval {time_t tv_sec; /* seconds */suseconds_t tv_usec; /* microseconds */ };int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval * timeout);函数参数 nfds委托内核检测的这三个集合中最大的文件描述符 1 内核需要线性遍历这些集合中的文件描述符这个值是循环结束的条件在Window中这个参数是无效的指定为-1即可 readfds文件描述符的集合, 内核只检测这个集合中文件描述符对应的读缓冲区 传入传出参数读集合一般情况下都是需要检测的这样才知道通过哪个文件描述符接收数据 writefds文件描述符的集合, 内核只检测这个集合中文件描述符对应的写缓冲区 传入传出参数如果不需要使用这个参数可以指定为NULL exceptfds文件描述符的集合, 内核检测集合中文件描述符是否有异常状态 传入传出参数如果不需要使用这个参数可以指定为NULL timeout超时时长用来强制解除select()函数的阻塞的 NULL函数检测不到就绪的文件描述符会一直阻塞。等待固定时长秒函数检测不到就绪的文件描述符在指定时长之后强制解除阻塞函数返回0不等待函数不会阻塞直接将该参数对应的结构体初始化为0即可。 函数返回值 大于0成功返回集合中已就绪的文件描述符的总个数等于-1函数调用失败等于0超时没有检测到就绪的文件描述符 另外初始化fd_set类型的参数还需要使用相关的一些列操作函数具体如下 // 将文件描述符fd从set集合中删除 将fd对应的标志位设置为0 void FD_CLR(int fd, fd_set *set); // 判断文件描述符fd是否在set集合中 读一下fd对应的标志位到底是0还是1 int FD_ISSET(int fd, fd_set *set); // 将文件描述符fd添加到set集合中 将fd对应的标志位设置为1 void FD_SET(int fd, fd_set *set); // 将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符 void FD_ZERO(fd_set *set);7.2.2 细节描述 在select()函数中第2、3、4个参数都是fd_set类型它表示一个文件描述符的集合类似于信号集 sigset_t这个类型的数据有128个字节也就是1024个标志位和内核中文件描述符表中的文件描述符个数是一样的。 sizeof(fd_set) 128 字节 * 8 1024 bit // int [32]这不是巧合是故意为之。这块内存中的每一个bit 和 文件描述符表中的每一个文件描述符是一一对应的关系这样就可以使用最小的存储空间将要表达的意思描述出来了。 下图中的fd_set中存储了要委托内核检测读缓冲区的文件描述符集合。 如果集合中的标志位为0代表不检测这个文件描述符状态如果集合中的标志位为1代表检测这个文件描述符状态 内核在遍历这个读集合的过程中如果被检测的文件描述符对应的读缓冲区中没有数据内核将修改这个文件描述符在读集合fd_set中对应的标志位改为0如果有数据那么这个标志位的值不变还是1。 当select()函数解除阻塞之后被内核修改过的读集合通过参数传出此时集合中只要标志位的值为1那么它对应的文件描述符肯定是就绪的我们就可以基于这个文件描述符和客户端建立新连接或者通信了。 7.3 并发处理 7.3.1 处理流程 如果在服务器基于select实现并发其处理流程如下 创建监听的套接字 lfd socket();将监听的套接字和本地的IP和端口绑定 bind()给监听的套接字设置监听 listen()创建一个文件描述符集合 fd_set用于存储需要检测读事件的所有的文件描述符 通过 FD_ZERO() 初始化通过 FD_SET() 将监听的文件描述符放入检测的读集合中 循环调用select()周期性的对所有的文件描述符进行检测select() 解除阻塞返回得到内核传出的满足条件的就绪的文件描述符集合 通过FD_ISSET() 判断集合中的标志位是否为 1 如果这个文件描述符是监听的文件描述符调用 accept() 和客户端建立连接 将得到的新的通信的文件描述符通过FD_SET() 放入到检测集合中 如果这个文件描述符是通信的文件描述符调用通信函数和客户端通信 如果客户端和服务器断开了连接使用FD_CLR()将这个文件描述符从检测集合中删除如果没有断开连接正常通信即可 重复第6步 7.3.2 通信代码 服务器端代码如下 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建监听的fdint lfd socket(AF_INET, SOCK_STREAM, 0);// 2. 绑定struct sockaddr_in addr;addr.sin_family AF_INET;addr.sin_port htons(9999);addr.sin_addr.s_addr INADDR_ANY;bind(lfd, (struct sockaddr*)addr, sizeof(addr));// 3. 设置监听listen(lfd, 128);// 将监听的fd的状态检测委托给内核检测int maxfd lfd;// 初始化检测的读集合fd_set rdset;fd_set rdtemp;// 清零FD_ZERO(rdset);// 将监听的lfd设置到检测的读集合中FD_SET(lfd, rdset);// 通过select委托内核检测读集合中的文件描述符状态, 检测read缓冲区有没有数据// 如果有数据, select解除阻塞返回// 应该让内核持续检测while(1){// 默认阻塞// rdset 中是委托内核检测的所有的文件描述符rdtemp rdset;int num select(maxfd1, rdtemp, NULL, NULL, NULL);// rdset中的数据被内核改写了, 只保留了发生变化的文件描述的标志位上的1, 没变化的改为0// 只要rdset中的fd对应的标志位为1 - 缓冲区有数据了// 判断// 有没有新连接if(FD_ISSET(lfd, rdtemp)){// 接受连接请求, 这个调用不阻塞struct sockaddr_in cliaddr;int cliLen sizeof(cliaddr);int cfd accept(lfd, (struct sockaddr*)cliaddr, cliLen);// 得到了有效的文件描述符// 通信的文件描述符添加到读集合// 在下一轮select检测的时候, 就能得到缓冲区的状态FD_SET(cfd, rdset);// 重置最大的文件描述符maxfd cfd maxfd ? cfd : maxfd;}// 没有新连接, 通信for(int i0; imaxfd1; i){// 判断从监听的文件描述符之后到maxfd这个范围内的文件描述符是否读缓冲区有数据if(i ! lfd FD_ISSET(i, rdtemp)){// 接收数据char buf[10] {0};// 一次只能接收10个字节, 客户端一次发送100个字节// 一次是接收不完的, 文件描述符对应的读缓冲区中还有数据// 下一轮select检测的时候, 内核还会标记这个文件描述符缓冲区有数据 - 再读一次// 循环会一直持续, 知道缓冲区数据被读完位置int len read(i, buf, sizeof(buf));if(len 0){printf(客户端关闭了连接...\n);// 将检测的文件描述符从读集合中删除FD_CLR(i, rdset);close(i);}else if(len 0){// 收到了数据// 发送数据write(i, buf, strlen(buf)1);}else{// 异常perror(read);}}}}return 0; }在上面的代码中创建了两个fd_set变量用于保存要检测的读集合 // 初始化检测的读集合 fd_set rdset; fd_set rdtemp;rdset用于保存要检测的原始数据这个变量不能作为参数传递给select函数因为在函数内部这个变量中的值会被内核修改函数调用完毕返回之后里边就不是原始数据了大部分情况下是值为1的标志位变少了不可能每一轮检测所有的文件描述符都是就行的状态。因此需要通过rdtemp变量将原始数据传递给内核select() 调用完毕之后再将内核数据传出这两个变量的功能是不一样的。 客户端代码: #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建用于通信的套接字int fd socket(AF_INET, SOCK_STREAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 连接服务器struct sockaddr_in addr;addr.sin_family AF_INET; // ipv4addr.sin_port htons(9999); // 服务器监听的端口, 字节序应该是网络字节序inet_pton(AF_INET, 127.0.0.1, addr.sin_addr.s_addr);int ret connect(fd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(connect);exit(0);}// 通信while(1){// 读数据char recvBuf[1024];// 写数据// sprintf(recvBuf, data: %d\n, i);fgets(recvBuf, sizeof(recvBuf), stdin);write(fd, recvBuf, strlen(recvBuf)1);// 如果客户端没有发送数据, 默认阻塞read(fd, recvBuf, sizeof(recvBuf));printf(recv buf: %s\n, recvBuf);sleep(1);}// 释放资源close(fd); return 0; }客户端不需要使用IO多路转接进行处理因为客户端和服务器的对应关系是 1N也就是说客户端是比较专一的只能和一个连接成功的服务器通信。 虽然使用select这种IO多路转接技术可以降低系统开销提高程序效率但是它也有局限性 待检测集合第2、3、4个参数需要频繁的在用户区和内核区之间进行数据的拷贝效率低内核对于select传递进来的待检测集合的检测方式是线性的 如果集合内待检测的文件描述符很多检测效率会比较低如果集合内待检测的文件描述符相对较少检测效率会比较高 使用select能够检测的最大文件描述符个数有上限默认是1024这是在内核中被写死了的。 8. IO多路转接复用之poll 8.1 poll函数 poll的机制与select类似与select在本质上没有多大差别使用方法也类似下面的是对于二者的对比 内核对应文件描述符的检测也是以线性的方式进行轮询根据描述符的状态进行处理poll和select检测的文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝它的开销随着文件描述符数量的增加而线性增大从而效率也会越来越低。select检测的文件描述符个数上限是1024poll没有最大文件描述符数量的限制select可以跨平台使用poll只能在Linux平台使用 poll函数的函数原型如下 #include poll.h // 每个委托poll检测的fd都对应这样一个结构体 struct pollfd {int fd; /* 委托内核检测的文件描述符 */short events; /* 委托内核检测文件描述符的什么事件 */short revents; /* 文件描述符实际发生的事件 - 传出 */ };struct pollfd myfd[100]; int poll(struct pollfd *fds, nfds_t nfds, int timeout);函数参数 fds: 这是一个struct pollfd类型的数组, 里边存储了待检测的文件描述符的信息这个数组中有三个成员 fd委托内核检测的文件描述符events委托内核检测的fd事件输入、输出、错误每一个事件有多个取值revents这是一个传出参数数据由内核写入存储内核检测之后的结果 nfds: 这是第一个参数数组中最后一个有效元素的下标 1也可以指定参数1数组的元素总个数timeout: 指定poll函数的阻塞时长 -1一直阻塞直到检测的集合中有就绪的文件描述符有事件产生解除阻塞0不阻塞不管检测集合中有没有已就绪的文件描述符函数马上返回大于0阻塞指定的毫秒ms数之后解除阻塞 函数返回值 失败 返回-1成功返回一个大于0的整数表示检测的集合中已就绪的文件描述符的总个数 8.2 测试代码 服务器端 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.h #include sys/select.h #include poll.hint main() {// 1.创建套接字int lfd socket(AF_INET, SOCK_STREAM, 0);if(lfd -1){perror(socket);exit(0);}// 2. 绑定 ip, portstruct sockaddr_in addr;addr.sin_port htons(9999);addr.sin_family AF_INET;addr.sin_addr.s_addr INADDR_ANY;int ret bind(lfd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(bind);exit(0);}// 3. 监听ret listen(lfd, 100);if(ret -1){perror(listen);exit(0);}// 4. 等待连接 - 循环// 检测 - 读缓冲区, 委托内核去处理// 数据初始化, 创建自定义的文件描述符集struct pollfd fds[1024];// 初始化for(int i0; i1024; i){fds[i].fd -1;fds[i].events POLLIN;}fds[0].fd lfd;int maxfd 0;while(1){// 委托内核检测ret poll(fds, maxfd1, -1);if(ret -1){perror(select);exit(0);}// 检测的度缓冲区有变化// 有新连接if(fds[0].revents POLLIN){// 接收连接请求struct sockaddr_in sockcli;int len sizeof(sockcli);// 这个accept是不会阻塞的int connfd accept(lfd, (struct sockaddr*)sockcli, len);// 委托内核检测connfd的读缓冲区int i;for(i0; i1024; i){if(fds[i].fd -1){fds[i].fd connfd;break;}}maxfd i maxfd ? i : maxfd;}// 通信, 有客户端发送数据过来for(int i1; imaxfd; i){// 如果在集合中, 说明读缓冲区有数据if(fds[i].revents POLLIN){char buf[128];int ret read(fds[i].fd, buf, sizeof(buf));if(ret -1){perror(read);exit(0);}else if(ret 0){printf(对方已经关闭了连接...\n);close(fds[i].fd);fds[i].fd -1;}else{printf(客户端say: %s\n, buf);write(fds[i].fd, buf, strlen(buf)1);}}}}close(lfd);return 0; }从上面的测试代码可以得知使用poll和select进行IO多路转接的处理思路是完全相同的但是使用poll编写的代码看起来会更直观一些select使用的位图的方式来标记要委托内核检测的文件描述符每个比特位对应一个唯一的文件描述符并且对这个fd_set类型的位图变量进行读写还需要借助一系列的宏函数操作比较麻烦。而poll直接将要检测的文件描述符的相关信息封装到了一个结构体struct pollfd中我们可以直接读写这个结构体变量。 另外poll的第二个参数有两种赋值方式但是都和第一个参数的数组有关系 使用参数1数组的元素个数使用参数1数组中存储的最后一个有效元素对应的下标值 1 内核会根据第二个参数传递的值对参数1数组中的文件描述符进行线性遍历这一点和select也是类似的。 客户端 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建用于通信的套接字int fd socket(AF_INET, SOCK_STREAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 连接服务器struct sockaddr_in addr;addr.sin_family AF_INET; // ipv4addr.sin_port htons(9999); // 服务器监听的端口, 字节序应该是网络字节序inet_pton(AF_INET, 127.0.0.1, addr.sin_addr.s_addr);int ret connect(fd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(connect);exit(0);}// 通信while(1){// 读数据char recvBuf[1024];// 写数据// sprintf(recvBuf, data: %d\n, i);fgets(recvBuf, sizeof(recvBuf), stdin);write(fd, recvBuf, strlen(recvBuf)1);// 如果客户端没有发送数据, 默认阻塞read(fd, recvBuf, sizeof(recvBuf));printf(recv buf: %s\n, recvBuf);sleep(1);}// 释放资源close(fd); return 0; }客户端不需要使用IO多路转接进行处理因为客户端和服务器的对应关系是 1N也就是说客户端是比较专一的只能和一个连接成功的服务器通信。 9. IO多路转接复用之epoll 9.1 概述 epoll 全称 eventpoll是 linux 内核实现IO多路转接/复用IO multiplexing的一个实现。IO多路转接的意思是在一个操作里同时监听多个输入输出源在其中一个或多个输入输出源可用的时候返回然后对其的进行读写操作。epoll是select和poll的升级版相较于这两个前辈epoll改进了工作方式因此它更加高效。 对于待检测集合select和poll是基于线性方式处理的epoll是基于红黑树来管理待检测集合的。select和poll每次都会线性扫描整个待检测集合集合越大速度越慢epoll使用的是回调机制效率高处理效率也不会随着检测集合的变大而下降select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题在epoll中内核和用户区使用的是共享内存基于mmap内存映射区实现省去了不必要的内存拷贝。我们需要对select和poll返回的集合进行判断才能知道哪些文件描述符是就绪的通过epoll可以直接得到已就绪的文件描述符集合无需再次检测使用epoll没有最大文件描述符的限制仅受系统中进程能打开的最大文件数目限制 当多路复用的文件数量庞大、IO流量频繁的时候一般不太适合使用select()和poll()这种情况下select()和poll()表现较差推荐使用epoll()。 9.2 操作函数 在epoll中一共提供是三个API函数分别处理不同的操作函数原型如下 #include sys/epoll.h // 创建epoll实例通过一棵红黑树管理待检测集合 int epoll_create(int size); // 管理红黑树上的文件描述符(添加、修改、删除) int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 检测epoll树中是否有就绪的文件描述符 int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);select/poll低效的原因之一是将“添加/维护待检测任务”和“阻塞进程/线程”两个步骤合二为一。每次调用select都需要这两步操作然而大多数应用场景中需要监视的socket个数相对固定并不需要每次都修改。epoll将这两个操作分开先用epoll_ctl()维护等待队列再调用epoll_wait()阻塞进程解耦。通过下图的对比显而易见epoll的效率得到了提升。 epoll_create()函数的作用是创建一个红黑树模型的实例用于管理待检测的文件描述符的集合。 int epoll_create(int size);函数参数 size在Linux内核2.6.8版本以后这个参数是被忽略的只需要指定一个大于0的数值就可以了。函数返回值 失败返回-1成功返回一个有效的文件描述符通过这个文件描述符就可以访问创建的epoll实例了 epoll_ctl()函数的作用是管理红黑树实例上的节点可以进行添加、删除、修改操作。 // 联合体, 多个变量共用同一块内存 typedef union epoll_data {void *ptr;int fd; // 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可uint32_t u32;uint64_t u64; } epoll_data_t;struct epoll_event {uint32_t events; /* Epoll events */epoll_data_t data; /* User data variable */ }; int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);函数参数 epfdepoll_create() 函数的返回值通过这个参数找到epoll实例op这是一个枚举值控制通过该函数执行什么操作 EPOLL_CTL_ADD往epoll模型中添加新的节点EPOLL_CTL_MOD修改epoll模型中已经存在的节点EPOLL_CTL_DEL删除epoll模型中的指定的节点 fd文件描述符即要添加/修改/删除的文件描述符eventepoll事件用来修饰第三个参数对应的文件描述符的指定检测这个文件描述符的什么事件 events委托epoll检测的事件 EPOLLIN读事件, 接收数据, 检测读缓冲区如果有数据该文件描述符就绪EPOLLOUT写事件, 发送数据, 检测写缓冲区如果可写该文件描述符就绪EPOLLERR异常事件 data用户数据变量这是一个联合体类型通常情况下使用里边的fd成员用于存储待检测的文件描述符的值在调用epoll_wait()函数的时候这个值会被传出。 函数返回值 失败返回-1成功返回0 epoll_wait()函数的作用是检测创建的epoll实例中有没有就绪的文件描述符。 int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);函数参数 epfdepoll_create() 函数的返回值, 通过这个参数找到epoll实例events传出参数, 这是一个结构体数组的地址, 里边存储了已就绪的文件描述符的信息maxevents修饰第二个参数, 结构体数组的容量元素个数timeout如果检测的epoll实例中没有已就绪的文件描述符该函数阻塞的时长, 单位ms 毫秒 0函数不阻塞不管epoll实例中有没有就绪的文件描述符函数被调用后都直接返回大于0如果epoll实例中没有已就绪的文件描述符函数阻塞对应的毫秒数再返回-1函数一直阻塞直到epoll实例中有已就绪的文件描述符之后才解除阻塞 函数返回值 成功 等于0函数是阻塞被强制解除了, 没有检测到满足条件的文件描述符大于0检测到的已就绪的文件描述符的总个数 失败返回-1 9.3 epoll的使用 9.3.1 操作步骤 在服务器端使用epoll进行IO多路转接的操作步骤如下 创建监听的套接字 int lfd socket(AF_INET, SOCK_STREAM, 0);设置端口复用可选 int opt 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));使用本地的IP与端口和监听的套接字进行绑定 int ret bind(lfd, (struct sockaddr*)serv_addr, sizeof(serv_addr));给监听的套接字设置监听 listen(lfd, 128);创建epoll实例对象 int epfd epoll_create(100);将用于监听的套接字添加到epoll实例中 struct epoll_event ev; ev.events EPOLLIN; // 检测lfd读读缓冲区是否有数据 ev.data.fd lfd; int ret epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev);检测添加到epoll实例中的文件描述符是否已就绪并将这些已就绪的文件描述符进行处理 int num epoll_wait(epfd, evs, size, -1);如果是监听的文件描述符和新客户端建立连接将得到的文件描述符添加到epoll实例中 int cfd accept(curfd, NULL, NULL); ev.events EPOLLIN; ev.data.fd cfd;// 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了 epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev);如果是通信的文件描述符和对应的客户端通信如果连接已断开将该文件描述符从epoll实例中删除 int len recv(curfd, buf, sizeof(buf), 0); if(len 0) {// 将这个文件描述符从epoll模型中删除epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);close(curfd); } else if(len 0) {send(curfd, buf, len, 0); }重复第7步的操作 9.3.2 示例代码 #include stdio.h #include ctype.h #include unistd.h #include stdlib.h #include sys/types.h #include sys/stat.h #include string.h #include arpa/inet.h #include sys/socket.h #include sys/epoll.h// server int main(int argc, const char* argv[]) {// 创建监听的套接字int lfd socket(AF_INET, SOCK_STREAM, 0);if(lfd -1){perror(socket error);exit(1);}// 绑定struct sockaddr_in serv_addr;memset(serv_addr, 0, sizeof(serv_addr));serv_addr.sin_family AF_INET;serv_addr.sin_port htons(9999);serv_addr.sin_addr.s_addr htonl(INADDR_ANY); // 本地多有的// 设置端口复用int opt 1;setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));// 绑定端口int ret bind(lfd, (struct sockaddr*)serv_addr, sizeof(serv_addr));if(ret -1){perror(bind error);exit(1);}// 监听ret listen(lfd, 64);if(ret -1){perror(listen error);exit(1);}// 现在只有监听的文件描述符// 所有的文件描述符对应读写缓冲区状态都是委托内核进行检测的epoll// 创建一个epoll模型int epfd epoll_create(100);if(epfd -1){perror(epoll_create);exit(0);}// 往epoll实例中添加需要检测的节点, 现在只有监听的文件描述符struct epoll_event ev;ev.events EPOLLIN; // 检测lfd读读缓冲区是否有数据ev.data.fd lfd;ret epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev);if(ret -1){perror(epoll_ctl);exit(0);}struct epoll_event evs[1024];int size sizeof(evs) / sizeof(struct epoll_event);// 持续检测while(1){// 调用一次, 检测一次int num epoll_wait(epfd, evs, size, -1);for(int i0; inum; i){// 取出当前的文件描述符int curfd evs[i].data.fd;// 判断这个文件描述符是不是用于监听的if(curfd lfd){// 建立新的连接int cfd accept(curfd, NULL, NULL);// 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了ev.events EPOLLIN; // 读缓冲区是否有数据ev.data.fd cfd;ret epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev);if(ret -1){perror(epoll_ctl-accept);exit(0);}}else{// 处理通信的文件描述符// 接收数据char buf[1024];memset(buf, 0, sizeof(buf));int len recv(curfd, buf, sizeof(buf), 0);if(len 0){printf(客户端已经断开了连接\n);// 将这个文件描述符从epoll模型中删除epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);close(curfd);}else if(len 0){printf(客户端say: %s\n, buf);send(curfd, buf, len, 0);}else{perror(recv);exit(0);} }}}return 0; }当在服务器端循环调用epoll_wait()的时候就会得到一个就绪列表并通过该函数的第二个参数传出 struct epoll_event evs[1024]; int num epoll_wait(epfd, evs, size, -1);每当epoll_wait()函数返回一次在evs中最多可以存储size个已就绪的文件描述符信息但是在这个数组中实际存储的有效元素个数为num个如果在这个epoll实例的红黑树中已就绪的文件描述符很多并且evs数组无法将这些信息全部传出那么这些信息会在下一次epoll_wait()函数返回的时候被传出。 通过evs数组被传递出的每一个有效元素里边都包含了已就绪的文件描述符的相关信息这些信息并不是凭空得来的这取决于我们在往epoll实例中添加节点的时候往节点中初始化了哪些数据 struct epoll_event ev; // 节点初始化 ev.events EPOLLIN; ev.data.fd lfd; // 使用了联合体中 fd 成员 // 添加待检测节点到epoll实例中 int ret epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev);在添加节点的时候需要对这个struct epoll_event类型的节点进行初始化当这个节点对应的文件描述符变为已就绪状态这些被传入的初始化信息就会被原样传出这个对应关系必须要搞清楚。 9.4 epoll的工作模式 9.4.1 水平模式 水平模式可以简称为LT模式LTlevel triggered是缺省的工作方式并且同时支持block和no-block socket。在这种做法中内核通知使用者哪些文件描述符已经就绪之后就可以对这些已就绪的文件描述符进行IO操作了。如果我们不作任何操作内核还是会继续通知使用者。 水平模式的特点 读事件如果文件描述符对应的读缓冲区还有数据读事件就会被触发epoll_wait()解除阻塞 当读事件被触发epoll_wait()解除阻塞之后就可以接收数据了如果接收数据的buf很小不能全部将缓冲区数据读出那么读事件会继续被触发直到数据被全部读出如果接收数据的内存相对较大读数据的效率也会相对较高减少了读数据的次数因为读数据是被动的必须通过读事件才能知道有数据到达了因此对于读事件的检测是必须的 写事件如果文件描述符对应的写缓冲区可写写事件就会被触发epoll_wait()解除阻塞 当写事件被触发epoll_wait()解除阻塞之后就可以将数据写入到写缓冲区了写事件的触发发生在写数据之前而不是之后被写入到写缓冲区中的数据是由内核自动发送出去的如果写缓冲区没有被写满写事件会一直被触发因为写数据是主动的并且写缓冲区一般情况下都是可写的缓冲区不满因此对于写事件的检测不是必须的 9.4.2 边沿模式 边沿模式可简称为ET模式ETedge-triggered是高速工作方式只支持no-block socket。在这种模式下当文件描述符从未就绪变为就绪时内核会通过epoll通知使用者。然后它会假设使用者知道文件描述符已经就绪并且不会再为那个文件描述符发送更多的就绪通知only once。 如果我们对这个文件描述符做IO操作从而导致它再次变成未就绪当这个未就绪的文件描述符再次变成就绪状态内核会再次进行通知并且还是只通知一次。 ET模式在很大程度上减少了epoll事件被重复触发的次数因此效率要比LT模式高。 边沿模式的特点: 读事件当读缓冲区有新的数据进入读事件被触发一次没有新数据不会触发该事件 如果有新数据进入到读缓冲区读事件被触发epoll_wait()解除阻塞读事件被触发可以通过调用read()/recv()函数将缓冲区数据读出 如果数据没有被全部读走并且没有新数据进入读事件不会再次触发只通知一次如果数据被全部读走或只读走一部分此时有新数据进入读事件被触发且只通知一次 写事件当写缓冲区状态可写写事件只会触发一次 如果写缓冲区被检测到可写写事件被触发epoll_wait()解除阻塞写事件被触发就可以通过调用write()/send()函数将数据写入到写缓冲区中 写缓冲区从不满到被写满期间写事件只会被触发一次写缓冲区从满到不满状态变为可写写事件只会被触发一次 综上所述epoll的边沿模式下 epoll_wait()检测到文件描述符有新事件才会通知如果不是新的事件就不通知通知的次数比水平模式少效率比水平模式要高。 9.4.2.1 ET模式的设置 边沿模式不是默认的epoll模式需要额外进行设置。epoll设置边沿模式是非常简单的epoll管理的红黑树示例中每个节点都是struct epoll_event类型只需要将EPOLLET添加到结构体的events成员中即可 struct epoll_event ev; ev.events EPOLLIN | EPOLLET; // 设置边沿模式示例代码如下 int num epoll_wait(epfd, evs, size, -1); for(int i0; inum; i) {// 取出当前的文件描述符int curfd evs[i].data.fd;// 判断这个文件描述符是不是用于监听的if(curfd lfd){// 建立新的连接int cfd accept(curfd, NULL, NULL);// 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了// 读缓冲区是否有数据, 并且将文件描述符设置为边沿模式struct epoll_event ev;ev.events EPOLLIN | EPOLLET; ev.data.fd cfd;ret epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev);if(ret -1){perror(epoll_ctl-accept);exit(0);}} }9.4.2.2 设置非阻塞 对于写事件的触发一般情况下是不需要进行检测的因为写缓冲区大部分情况下都是有足够的空间可以进行数据的写入。对于读事件的触发就必须要检测了因为服务器也不知道客户端什么时候发送数据如果使用epoll的边沿模式进行读事件的检测有新数据达到只会通知一次那么必须要保证得到通知后将数据全部从读缓冲区中读出。那么应该如何读这些数据呢 方式1准备一块特别大的内存用于存储从读缓冲区中读出的数据但是这种方式有很大的弊端 内存的大小没有办法界定太大浪费内存太小又不够用系统能够分配的最大堆内存也是有上限的栈内存就更不必多言了 方式2循环接收数据 int len 0; while((len recv(curfd, buf, sizeof(buf), 0)) 0) {// 数据处理... }这样做也是有弊端的因为套接字操作默认是阻塞的当读缓冲区数据被读完之后读操作就阻塞了也就是调用的read()/recv()函数被阻塞了当前进程/线程被阻塞之后就无法处理其他操作了。 要解决阻塞问题就将套接字默认的阻塞行为修改为非阻塞需使用fcntl()函数进行处理 // 设置完成之后, 读写都变成了非阻塞模式 int flag fcntl(cfd, F_GETFL); flag | O_NONBLOCK; fcntl(cfd, F_SETFL, flag);通过上述分析就可以得出一个结论epoll在边沿模式下必须要将套接字设置为非阻塞模式但是这样就会引发另外的一个bug在非阻塞模式下循环地将读缓冲区数据读到本地内存中当缓冲区数据被读完了调用的read()/recv()函数还会继续从缓冲区中读数据此时函数调用就失败了返回-1对应的全局变量 errno 值为 EAGAIN 或者 EWOULDBLOCK如果打印错误信息会得到如下的信息Resource temporarily unavailable // 非阻塞模式下recv() / read()函数返回值 len -1 int len recv(curfd, buf, sizeof(buf), 0); if(len -1) {if(errno EAGAIN){printf(数据读完了...\n);}else{perror(recv);exit(0);} }9.4.2.3 示例代码 #include stdio.h #include ctype.h #include unistd.h #include stdlib.h #include sys/types.h #include sys/stat.h #include string.h #include arpa/inet.h #include sys/socket.h #include sys/epoll.h #include fcntl.h #include errno.h// server int main(int argc, const char* argv[]) {// 创建监听的套接字int lfd socket(AF_INET, SOCK_STREAM, 0);if(lfd -1){perror(socket error);exit(1);}// 绑定struct sockaddr_in serv_addr;memset(serv_addr, 0, sizeof(serv_addr));serv_addr.sin_family AF_INET;serv_addr.sin_port htons(9999);serv_addr.sin_addr.s_addr htonl(INADDR_ANY); // 本地多有的// 127.0.0.1// inet_pton(AF_INET, 127.0.0.1, serv_addr.sin_addr.s_addr);// 设置端口复用int opt 1;setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));// 绑定端口int ret bind(lfd, (struct sockaddr*)serv_addr, sizeof(serv_addr));if(ret -1){perror(bind error);exit(1);}// 监听ret listen(lfd, 64);if(ret -1){perror(listen error);exit(1);}// 现在只有监听的文件描述符// 所有的文件描述符对应读写缓冲区状态都是委托内核进行检测的epoll// 创建一个epoll模型int epfd epoll_create(100);if(epfd -1){perror(epoll_create);exit(0);}// 往epoll实例中添加需要检测的节点, 现在只有监听的文件描述符struct epoll_event ev;ev.events EPOLLIN; // 检测lfd读读缓冲区是否有数据ev.data.fd lfd;ret epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev);if(ret -1){perror(epoll_ctl);exit(0);}struct epoll_event evs[1024];int size sizeof(evs) / sizeof(struct epoll_event);// 持续检测while(1){// 调用一次, 检测一次int num epoll_wait(epfd, evs, size, -1);printf( num: %d\n, num);for(int i0; inum; i){// 取出当前的文件描述符int curfd evs[i].data.fd;// 判断这个文件描述符是不是用于监听的if(curfd lfd){// 建立新的连接int cfd accept(curfd, NULL, NULL);// 将文件描述符设置为非阻塞// 得到文件描述符的属性int flag fcntl(cfd, F_GETFL);flag | O_NONBLOCK;fcntl(cfd, F_SETFL, flag);// 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了// 通信的文件描述符检测读缓冲区数据的时候设置为边沿模式ev.events EPOLLIN | EPOLLET; // 读缓冲区是否有数据ev.data.fd cfd;ret epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev);if(ret -1){perror(epoll_ctl-accept);exit(0);}}else{// 处理通信的文件描述符// 接收数据char buf[5];memset(buf, 0, sizeof(buf));// 循环读数据while(1){int len recv(curfd, buf, sizeof(buf), 0);if(len 0){// 非阻塞模式下和阻塞模式是一样的 判断对方是否断开连接printf(客户端断开了连接...\n);// 将这个文件描述符从epoll模型中删除epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);close(curfd);break;}else if(len 0){// 通信// 接收的数据打印到终端write(STDOUT_FILENO, buf, len);// 发送数据send(curfd, buf, len, 0);}else{// len -1if(errno EAGAIN){printf(数据读完了...\n);break;}else{perror(recv);exit(0);}}}}}}return 0; }10. 基于UDP的套接字通信 udp是一个面向无连接的不安全的报式传输层协议udp的通信过程默认也是阻塞的。 UDP通信不需要建立连接 因此不需要进行connect()操作 UDP通信过程中每次都需要指定数据接收端的IP和端口和发快递差不多 UDP不对收到的数据进行排序在UDP报文的首部中并没有关于数据顺序的信息 UDP对接收到的数据报不回复确认信息,发送端不知道数据是否被正确接收,也不会重发数据。 如果发生了数据丢失不存在丢一半的情况如果丢当前这个数据包就全部丢失了 10.1 通信流程 使用UDP进行通信服务器和客户端的处理步骤比TCP要简单很多并且两端是对等的 通信的处理流程几乎是一样的也就是说并没有严格意义上的客户端和服务器端。UDP的通信流程如下 10.1.1 服务器端 假设服务器端是接收数据的角色 创建通信的套接字 // 第二个参数是 SOCK_DGRAM, 第三个参数0表示使用报式协议中的udp int fd socket(AF_INET, SOCK_DGRAM, 0);使用通信的套接字和本地的IP和端口绑定IP和端口需要转换为大端(可选) bind();通信 // 接收数据 recvfrom(); // 发送数据 sendto();关闭套接字文件描述符 close(fd);10.1.2 客户端 假设客户端是发送数据的角色 创建通信的套接字 // 第二个参数是 SOCK_DGRAM, 第三个参数0表示使用报式协议中的udp int fd socket(AF_INET, SOCK_DGRAM, 0);通信 // 接收数据 recvfrom(); // 发送数据 sendto();关闭套接字文件描述符 close(fd);在UDP通信过程中哪一端是接收数据的角色那么这个接收端就必须绑定一个固定的端口如果某一端不需要接收数据这个绑定操作就可省略不写通信的套接字会自动绑定一个随机端口。 10.2 通信函数 基于UDP进行套接字通信创建套接字的函数还是socket()但是第二个参数的值需要指定为SOCK_DGRAM通过该参数指定要创建一个基于报式传输协议的套接字最后一个参数指定为0表示使用报式协议中的UDP协议。 int socket(int domain, int type, int protocol);参数: domain地址族协议AF_INET - IPv4AF_INET6- IPv6type使用的传输协议类型报式传输协议需要指定为 SOCK_DGRAMprotocol指定为0表示使用的默认报式传输协议为 UDP 返回值函数调用成功返回一个可用的文件描述符大于0调用失败返回-1 另外进行UDP通信通信过程虽然默认还是阻塞的但是通信函数和TCP不同 操作函数原型如下 // 接收数据, 如果没有数据,该函数阻塞 ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);参数: sockfd: 基于udp的通信的文件描述符buf: 指针指向的地址用来存储接收的数据len: buf指针指向的内存的容量, 最多能存储多少字节flags: 设置套接字属性一般使用默认属性指定为0即可src_addr: 发送数据的一端的地址信息IP和端口都存储在这里边, 是大端存储的 如果这个参数中的信息对当前业务处理没有用处, 可以指定为NULL, 不保存这些信息 addrlen: 类似于accept() 函数的最后一个参数, 是一个传入传出参数 传入的是src_addr参数指向的内存的大小, 传出的也是这块内存的大小如果src_addr参数指定为NULL, 这个参数也指定为NULL即可 返回值成功返回接收的字节数失败返回-1 // 发送数据函数 ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);参数: sockfd: 基于udp的通信的文件描述符buf: 这个指针指向的内存中存储了要发送的数据len: 要发送的数据的实际长度flags: 设置套接字属性一般使用默认属性指定为0即可dest_addr: 接收数据的一端对应的地址信息, 大端的IP和端口addrlen: 参数 dest_addr 指向的内存大小 返回值函数调用成功返回实际发送的字节数调用失败返回-1 10.3 通信代码 在UDP通信过程中服务器和客户端都可以作为数据的发送端和数据接收端假设服务器端是被动接收数据客户端是主动发送数据那么在服务器端就必须绑定固定的端口了。 10.3.1 服务器端 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建通信的套接字int fd socket(AF_INET, SOCK_DGRAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 通信的套接字和本地的IP与端口绑定struct sockaddr_in addr;addr.sin_family AF_INET;addr.sin_port htons(9999); // 大端addr.sin_addr.s_addr INADDR_ANY; // 0.0.0.0int ret bind(fd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(bind);exit(0);}char buf[1024];char ipbuf[64];struct sockaddr_in cliaddr;int len sizeof(cliaddr);// 3. 通信while(1){// 接收数据memset(buf, 0, sizeof(buf));int rlen recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr*)cliaddr, len);printf(客户端的IP地址: %s, 端口: %d\n,inet_ntop(AF_INET, cliaddr.sin_addr.s_addr, ipbuf, sizeof(ipbuf)),ntohs(cliaddr.sin_port));printf(客户端say: %s\n, buf);// 回复数据// 数据回复给了发送数据的客户端sendto(fd, buf, rlen, 0, (struct sockaddr*)cliaddr, sizeof(cliaddr));}close(fd);return 0; }作为数据接收端服务器端通过bind()函数绑定了固定的端口然后基于这个固定的端口通过recvfrom()函数接收客户端发送的数据同时通过这个函数也得到了数据发送端的地址信息recvfrom的第三个参数这样就可以通过得到的地址信息通过sendto()函数给客户端回复数据了。 10.3.2 客户端 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建通信的套接字int fd socket(AF_INET, SOCK_DGRAM, 0);if(fd -1){perror(socket);exit(0);}// 初始化服务器地址信息struct sockaddr_in seraddr;seraddr.sin_family AF_INET;seraddr.sin_port htons(9999); // 大端inet_pton(AF_INET, 192.168.1.100, seraddr.sin_addr.s_addr);char buf[1024];char ipbuf[64];struct sockaddr_in cliaddr;int len sizeof(cliaddr);int num 0;// 2. 通信while(1){sprintf(buf, hello, udp %d....\n, num);// 发送数据, 数据发送给了服务器sendto(fd, buf, strlen(buf)1, 0, (struct sockaddr*)seraddr, sizeof(seraddr));// 接收数据memset(buf, 0, sizeof(buf));recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);printf(服务器say: %s\n, buf);sleep(1);}close(fd);return 0; }作为数据发送端客户端不需要绑定固定端口客户端使用的端口是随机绑定的也可以调用bind()函数手动进行绑定。客户端在接收服务器端回复的数据的时候需要调用recvfrom()函数因为客户端在发送数据之前就已经知道服务器绑定的固定的IP和端口信息了所以接收服务器数据的时候就可以不保存服务器端的地址信息直接将函数的最后两个参数指定为NULL即可。 11. UDP特性之广播 11.1 广播的特点 广播的UDP的特性之一通过广播可以向子网中多台计算机发送消息并且子网中所有的计算机都可以接收到发送方发送的消息每个广播消息都包含一个特殊的IP地址这个IP中子网内主机标志部分的二进制全部为1 即点分十进制IP的最后一部分是255。点分十进制的IP地址每一部分是1字节最大值为255比如192.168.1.100 前两部分192.168表示当前网络是局域网第三部分1表示局域网中的某一个网段最大值为 255第四部分100用于标记当前网段中的某一台主机最大值为255每个网段都有一个特殊的广播地址即192.168.xxx.255 广播分为两端即数据发送端和数据接收端通过广播的方式发送数据发送端和接收端的关系是 1:N 发送广播消息的一端通过广播地址可以将消息同时发送到局域网的多台主机上数据接收端 在发送广播消息的时候必须要把数据发送到广播地址上 广播只能在局域网内使用广域网是无法使用UDP进行广播的 只要发送端在发送广播消息数据接收端就能收到广播消息消息的接收是无法拒绝的除非将接收端的进程关闭就接收不到了。 UDP的广播和日常的广播是一样的都是一种快速传播消息的方式因此广播的开销很小发送端使用一个广播地址就可以将数据发送到多个接收数据的终端上如果不使用广播就需要进行多次发送才能将数据分别发送到不同的主机上。 11.2 设置广播属性 基于UDP虽然可以进行数据的广播但是这个属性默认是关闭的如果需要对数据进行广播那么需要在广播端代码中开启广播属性需要通过套接字选项函数进行设置该函数原型为 int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);参数: sockfd进行UDP通信的文件描述符level: 套接字级别需要设置为 SOL_SOCKEToptname选项名此处要设置udp的广播属性该参数需要指定为SO_BROADCASToptval如果是设置广播属性该指针实际指向一块int类型的内存 该整型值为0关闭广播属性该整形值为1打开广播属性 optlenoptval指针指向的内存大小即sizeof(int) 返回值函数调用成功返回0失败返回-1 11.3 广播通信流程 如果使用UDP在局域网范围内进行消息的广播一般情况下广播端只发送数据接收端只接受广播消息。因此在数据接收端需要绑定固定的端口广播端则不需要手动绑定固定端口自动随机绑定即可。 数据发送端 创建通信的套接字 // 第二个参数是 SOCK_DGRAM, 第三个参数0表示使用报式协议中的udp int fd socket(AF_INET, SOCK_DGRAM, 0);主动发送数据不需要手动绑定固定端口自动随机分配就可以了因此直接设置广播属性 int opt 1; setsockopt(fd, SOL_SOCKET, SO_BROADCAST, opt, sizeof(opt));使用广播地址发送广播数据到接收端绑定的固定端口上 sendto();关闭套接字文件描述符 close(fd);数据接收端 创建通信的套接字 // 第二个参数是 SOCK_DGRAM, 第三个参数0表示使用报式协议中的udp int fd socket(AF_INET, SOCK_DGRAM, 0);因为是被动接收数据的一端所以必须要绑定固定的端口和本地IP地址 bind();接收广播消息 recvfrom();关闭套接字文件描述符 close(fd);11.4 通信代码 广播端 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建通信的套接字int fd socket(AF_INET, SOCK_DGRAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 设置广播属性int opt 1;setsockopt(fd, SOL_SOCKET, SO_BROADCAST, opt, sizeof(opt));char buf[1024];struct sockaddr_in cliaddr;int len sizeof(cliaddr);cliaddr.sin_family AF_INET;cliaddr.sin_port htons(9999); // 接收端需要绑定9999端口// 只要主机在237网段, 并且绑定了9999端口, 这个接收端就能收到广播消息inet_pton(AF_INET, 192.168.237.255, cliaddr.sin_addr.s_addr);// 3. 通信int num 0;while(1){sprintf(buf, hello, client...%d\n, num);// 数据广播sendto(fd, buf, strlen(buf)1, 0, (struct sockaddr*)cliaddr, len);printf(发送的广播的数据: %s\n, buf);sleep(1);}close(fd);return 0; }注意发送广播消息一端必须要开启UDP的广播属性并且发送消息的地址必须是当前发送端所在网段的广播地址这样才能通过调用一个消息发送函数将消息同时发送N台接收端主机上。 接收端 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建通信的套接字int fd socket(AF_INET, SOCK_DGRAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 通信的套接字和本地的IP与端口绑定struct sockaddr_in addr;addr.sin_family AF_INET;addr.sin_port htons(9999); // 大端addr.sin_addr.s_addr INADDR_ANY; // 0.0.0.0int ret bind(fd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(bind);exit(0);}char buf[1024];// 3. 通信while(1){// 接收广播消息memset(buf, 0, sizeof(buf));// 阻塞等待数据达到recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);printf(接收到的广播消息: %s\n, buf);}close(fd);return 0; }对于接收广播消息的一端必须要绑定固定的端口并由广播端将广播消息发送到这个端口上因此所有接收端都应绑定相同的端口这样才能同时收到广播数据。 12. UDP特性之组播多播 12.1 组播的特点 组播也可以称之为多播这也是UDP的特性之一。组播是主机间一对多的通讯模式是一种允许一个或多个组播源发送同一报文到多个接收者的技术。组播源将一份报文发送到特定的组播地址组播地址不同于单播地址它并不属于特定某个主机而是属于一组主机。一个组播地址表示一个群组需要接收组播报文的接收者都加入这个群组。 广播只能在局域网访问内使用组播既可以在局域网中使用也可以用于广域网在发送广播消息的时候连接到局域网的客户端不管想不想都会接收到广播数据组播可以控制发送端的消息能够被哪些接收端接收更灵活和人性化。广播使用的是广播地址组播需要使用组播地址。广播和组播属性默认都是关闭的如果使用需要通过setsockopt()函数进行设置。 组播需要使用组播地址在 IPv4 中它的范围从 224.0.0.0 到 239.255.255.255并被划分为局部链接多播地址、预留多播地址和管理权限多播地址三类: IP地址说明224.0.0.0~224.0.0.255局部链接多播地址是为路由协议和其它用途保留的地只能用于局域网中路由器是不会转发的地址 224.0.0.0不能用是保留地址224.0.1.0~224.0.1.255为用户可用的组播地址临时组地址可以用于 Internet 上的。224.0.2.0~238.255.255.255用户可用的组播地址临时组地址全网范围内有效239.0.0.0~239.255.255.255为本地管理组播地址仅在特定的本地范围内有效 组播地址不属于任何服务器或个人它有点类似一个微信群号任何成员组播源往微信群组播IP发送消息组播数据这个群里的成员组播接收者都会接收到此消息。 12.2 设置组播属性 如果使用组播进行数据的传输不管是消息发送端还是接收端都需要进行相关的属性设置设置函数使用的是同一个即setsockopt()。 12.2.1 发送端 发送组播消息的一端需要设置组播属性具体的设置方式如下 int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);参数 sockfd用于UDP通信的套接字level套接字级别设置组播属性需要将该参数指定为IPPTOTO_IPoptname: 套接字选项名设置组播属性需要将该参数指定为IP_MULTICAST_IFoptval设置组播属性这个指针需要指向一个struct in_addr{} 类型的结构体地址这个结构体地址用于存储组播地址并且组播IP地址的存储方式是大端的。optlenoptval指针指向的内存大小即sizeof(struct in_addr) struct in_addr {in_addr_t s_addr; // unsigned int }; 返回值函数调用成功返回0调用失败返回-1 12.2.2 接收端 因为一个组播地址表示一个群组所以需要接收组播报文的接收者都加入这个群组和想要接收群消息就必须要先入群是一个道理。加入到这个组播群组的方式如下 int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);参数: sockfd基于udp的通信的套接字 level套接字级别加入到多播组该参数需要指定为IPPTOTO_IP optname套接字选项名加入到多播组该参数需要指定为IP_ADD_MEMBERSHIP optval加入到多播组这个指针应该指向一个struct ip_mreqn{}类型的结构体地址 optlenoptval指向的内存大小即sizeof(struct ip_mreqn) typedef unsigned int uint32_t; typedef uint32_t in_addr_t; struct sockaddr_in addr;struct in_addr {in_addr_t s_addr; // unsigned int };struct ip_mreqn {struct in_addr imr_multiaddr; // 组播地址/多播地址struct in_addr imr_address; // 本地地址int imr_ifindex; // 网卡的编号, 每个网卡都有一个编号 }; // 必须通过网卡名字才能得到网卡的编号: 可以通过 ifconfig 命令查看网卡名字 #include net/if.h // 将网卡名转换为网卡的编号, 参数是网卡的名字, 比如: ens33 // 返回值就是网卡的编号 unsigned int if_nametoindex(const char *ifname);12.3 组播通信流程 发送组播消息的一端需要将数据发送到组播地址和固定的端口上想要接收组播消息的终端需要绑定对应的固定端口然后加入到组播的群组最终就可以实现数据的共享。 12.3.1 发送端 创建通信的套接字 // 第二个参数是 SOCK_DGRAM, 第三个参数0表示使用报式协议中的udp int fd socket(AF_INET, SOCK_DGRAM, 0);主动发送数据的一端不需要手动绑定端口自动随机分配就可以了设置UDP组播属性 // 设置组播属性 struct in_addr opt; // 将组播地址初始化到这个结构体成员中 inet_pton(AF_INET, 239.0.1.10, opt.s_addr); setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, opt, sizeof(opt));使用组播地址发送组播消息到固定的端口接收端需要绑定这个端口 sendto();关闭套接字文件描述符 close(fd);12.3.2 接收端 创建通信的套接字 // 第二个参数是 SOCK_DGRAM, 第三个参数0表示使用报式协议中的udp int fd socket(AF_INET, SOCK_DGRAM, 0);绑定固定的端口发送端应该将数据发送到接收端绑定的端口上 bind();加入到组播的群组中入群之后就可以接受组播消息了。 // 加入到多播组 struct ip_mreqn opt; // 要加入到哪个多播组, 通过组播地址来区分 inet_pton(AF_INET, 239.0.1.10, opt.imr_multiaddr.s_addr); opt.imr_address.s_addr INADDR_ANY; opt.imr_ifindex if_nametoindex(ens33); setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, opt, sizeof(opt));接收组播数据 recvfrom();关闭套接字文件描述符 close(fd);12.4 通信代码 12.4.1 发送端 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.hint main() {// 1. 创建通信的套接字int fd socket(AF_INET, SOCK_DGRAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 设置组播属性struct in_addr opt;// 将组播地址初始化到这个结构体成员中即可inet_pton(AF_INET, 239.0.1.10, opt.s_addr);setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, opt, sizeof(opt));char buf[1024];struct sockaddr_in cliaddr;int len sizeof(cliaddr);cliaddr.sin_family AF_INET;cliaddr.sin_port htons(9999); // 接收端需要绑定9999端口// 发送组播消息, 需要使用组播地址, 和设置组播属性使用的组播地址一致就可以inet_pton(AF_INET, 239.0.1.10, cliaddr.sin_addr.s_addr);// 3. 通信int num 0;while(1){sprintf(buf, hello, client...%d\n, num);// 数据广播sendto(fd, buf, strlen(buf)1, 0, (struct sockaddr*)cliaddr, len);printf(发送的组播的数据: %s\n, buf);sleep(1);}close(fd);return 0; }注意在组播数据的发送端需要先设置组播属性发送的数据是通过sendto()函数发送到某一个组播地址上并且在程序中数据发送到了接收端的9999端口因此接收端程序必须要绑定这个端口才能收到组播消息。 12.4.2 接收端 #include stdio.h #include stdlib.h #include unistd.h #include string.h #include arpa/inet.h #include net/if.hint main() {// 1. 创建通信的套接字int fd socket(AF_INET, SOCK_DGRAM, 0);if(fd -1){perror(socket);exit(0);}// 2. 通信的套接字和本地的IP与端口绑定struct sockaddr_in addr;addr.sin_family AF_INET;addr.sin_port htons(9999); // 大端addr.sin_addr.s_addr INADDR_ANY; // 0.0.0.0int ret bind(fd, (struct sockaddr*)addr, sizeof(addr));if(ret -1){perror(bind);exit(0);}// 3. 加入到多播组struct ip_mreqn opt;// 要加入到哪个多播组, 通过组播地址来区分inet_pton(AF_INET, 239.0.1.10, opt.imr_multiaddr.s_addr);opt.imr_address.s_addr INADDR_ANY;opt.imr_ifindex if_nametoindex(ens33);setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, opt, sizeof(opt));char buf[1024];// 3. 通信while(1){// 接收广播消息memset(buf, 0, sizeof(buf));// 阻塞等待数据达到recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);printf(接收到的组播消息: %s\n, buf);}close(fd);return 0; }注意作为组播消息的接收端必须要先绑定一个固定端口发送端就可以把数据发送到这个固定的端口上了然后加入到组播的群组中一个组播地址可以看做是一个群组这样就可以接收到组播消息了。
文章转载自:
http://www.morning.tmrjb.cn.gov.cn.tmrjb.cn
http://www.morning.gybnk.cn.gov.cn.gybnk.cn
http://www.morning.tmxtr.cn.gov.cn.tmxtr.cn
http://www.morning.fhghy.cn.gov.cn.fhghy.cn
http://www.morning.rgxll.cn.gov.cn.rgxll.cn
http://www.morning.tcpnp.cn.gov.cn.tcpnp.cn
http://www.morning.trhrk.cn.gov.cn.trhrk.cn
http://www.morning.kqzxk.cn.gov.cn.kqzxk.cn
http://www.morning.tkzqw.cn.gov.cn.tkzqw.cn
http://www.morning.xrnh.cn.gov.cn.xrnh.cn
http://www.morning.kszkm.cn.gov.cn.kszkm.cn
http://www.morning.nfmlt.cn.gov.cn.nfmlt.cn
http://www.morning.mrcpy.cn.gov.cn.mrcpy.cn
http://www.morning.bhpsz.cn.gov.cn.bhpsz.cn
http://www.morning.ljyqn.cn.gov.cn.ljyqn.cn
http://www.morning.ykrg.cn.gov.cn.ykrg.cn
http://www.morning.ylsxk.cn.gov.cn.ylsxk.cn
http://www.morning.ltspm.cn.gov.cn.ltspm.cn
http://www.morning.xznrk.cn.gov.cn.xznrk.cn
http://www.morning.xtgzp.cn.gov.cn.xtgzp.cn
http://www.morning.tkqzr.cn.gov.cn.tkqzr.cn
http://www.morning.iiunion.com.gov.cn.iiunion.com
http://www.morning.tpps.cn.gov.cn.tpps.cn
http://www.morning.xqkjp.cn.gov.cn.xqkjp.cn
http://www.morning.lkpzx.cn.gov.cn.lkpzx.cn
http://www.morning.pmdlk.cn.gov.cn.pmdlk.cn
http://www.morning.gyqnc.cn.gov.cn.gyqnc.cn
http://www.morning.bpmdq.cn.gov.cn.bpmdq.cn
http://www.morning.wcgcm.cn.gov.cn.wcgcm.cn
http://www.morning.rgxll.cn.gov.cn.rgxll.cn
http://www.morning.snjpj.cn.gov.cn.snjpj.cn
http://www.morning.mqgqf.cn.gov.cn.mqgqf.cn
http://www.morning.mjqms.cn.gov.cn.mjqms.cn
http://www.morning.mzpd.cn.gov.cn.mzpd.cn
http://www.morning.nydgg.cn.gov.cn.nydgg.cn
http://www.morning.stwxr.cn.gov.cn.stwxr.cn
http://www.morning.rkwlg.cn.gov.cn.rkwlg.cn
http://www.morning.ntyanze.com.gov.cn.ntyanze.com
http://www.morning.gtcym.cn.gov.cn.gtcym.cn
http://www.morning.weiwt.com.gov.cn.weiwt.com
http://www.morning.gcftl.cn.gov.cn.gcftl.cn
http://www.morning.ztcwp.cn.gov.cn.ztcwp.cn
http://www.morning.rwrn.cn.gov.cn.rwrn.cn
http://www.morning.lhztj.cn.gov.cn.lhztj.cn
http://www.morning.mnjyf.cn.gov.cn.mnjyf.cn
http://www.morning.jwxmn.cn.gov.cn.jwxmn.cn
http://www.morning.spxsm.cn.gov.cn.spxsm.cn
http://www.morning.zmpsl.cn.gov.cn.zmpsl.cn
http://www.morning.qlry.cn.gov.cn.qlry.cn
http://www.morning.pbxkk.cn.gov.cn.pbxkk.cn
http://www.morning.mgbcf.cn.gov.cn.mgbcf.cn
http://www.morning.fdrch.cn.gov.cn.fdrch.cn
http://www.morning.ykrss.cn.gov.cn.ykrss.cn
http://www.morning.bbmx.cn.gov.cn.bbmx.cn
http://www.morning.nrbcx.cn.gov.cn.nrbcx.cn
http://www.morning.mqpdl.cn.gov.cn.mqpdl.cn
http://www.morning.nnhfz.cn.gov.cn.nnhfz.cn
http://www.morning.tmpsc.cn.gov.cn.tmpsc.cn
http://www.morning.qbtkg.cn.gov.cn.qbtkg.cn
http://www.morning.brbmf.cn.gov.cn.brbmf.cn
http://www.morning.ypbdr.cn.gov.cn.ypbdr.cn
http://www.morning.xdpjs.cn.gov.cn.xdpjs.cn
http://www.morning.mhlkc.cn.gov.cn.mhlkc.cn
http://www.morning.phtqr.cn.gov.cn.phtqr.cn
http://www.morning.qmbtn.cn.gov.cn.qmbtn.cn
http://www.morning.kbbmj.cn.gov.cn.kbbmj.cn
http://www.morning.hmxb.cn.gov.cn.hmxb.cn
http://www.morning.pmxw.cn.gov.cn.pmxw.cn
http://www.morning.lqlc.cn.gov.cn.lqlc.cn
http://www.morning.hgtr.cn.gov.cn.hgtr.cn
http://www.morning.wbqk.cn.gov.cn.wbqk.cn
http://www.morning.qkrqt.cn.gov.cn.qkrqt.cn
http://www.morning.gklxm.cn.gov.cn.gklxm.cn
http://www.morning.xqknl.cn.gov.cn.xqknl.cn
http://www.morning.mlyq.cn.gov.cn.mlyq.cn
http://www.morning.hylbz.cn.gov.cn.hylbz.cn
http://www.morning.svrud.cn.gov.cn.svrud.cn
http://www.morning.trsdm.cn.gov.cn.trsdm.cn
http://www.morning.rcttz.cn.gov.cn.rcttz.cn
http://www.morning.qtqjx.cn.gov.cn.qtqjx.cn
http://www.tj-hxxt.cn/news/222700.html

相关文章:

  • 最超值的郑州网站建设静态网页代码大全
  • 如何编程建设网站自己请问我做吉利网站吉利啊
  • 建设网站教程视频视频团队拓展总结
  • 宿迁做网站 宿迁网站建设芜湖市建设银行支行网站
  • 网站建设试题卷qt做网站
  • 优秀国外网站设计赏析代理网站开发
  • 店铺网站怎么建网页制作基础教程第2版答案
  • 搜索引擎是网站提供的搜索服务吗制作网页软件教程
  • 什么网站合适做流量大庆+网站建设
  • 成都大型网站建设网站建设服务英文
  • 苏州产品网站建设重庆模板建站软件
  • 建网站一条龙视频交易类网页
  • 做网站的费用如何写分录网络营销是什么模式
  • 做网站最便宜优质手机网站建设推荐
  • 江苏有什么网站找工程建设人员wordpress qq空间主题
  • 江苏威达建设有限公司网站甜品店网站开发背景
  • 厦门网站关键词优化电商公司网站
  • 做网站用哪种语言怎么做企业曝光引流网站
  • 摄影网站建设公司如何做网站热力图
  • wordpress 自定义文章类型潍坊市网站优化
  • 北京网站设计优刻上海十大代运营公司
  • 欢迎回来请牢记网站域名百度搜索广告价格
  • 聚美优品网站建设分析外贸流程及详细步骤
  • 沈阳自助模板建站wordpress newsletter 插件
  • 一起做网站逛市场网站优化如何提高排名
  • 网站建设朱宁新零售型网站开发
  • 学校网站建设与管理办法苏州高端网站建设机构
  • 厦门思明区建设局网站wordpress __
  • 网站建设公司推荐5788广州网站推广技巧
  • 网站直播间怎么做购物网站开发django