本篇博客整理了 socket 套接字编程的相关内容,包括 socket 网络通信原理、socket 相关的系统调用接口等,分别演示了基于UDP协议、TCP协议的 socket 网络编程,旨在让读者更加深入理解网络通信原理和设计,对网络编程有初步的认识和掌握。
目录
一、Socket 通信
1)源与目的系列
.1- 源 IP 地址 vs 目的 IP 地址
.2- 源 MAC 地址 vs 目的 MAC 地址
.3- 源端口号 vs 目的端口号
2)socket 与网络通信本质
3)TCP 协议和 UDP 协议
4)网络字节序为大端
二、Socket 系列接口
1)TCP/UDP 通用接口
.1- 创建套接字 socket()
.2- 绑定端口号 bind()
2)UDP 专用接口
3)TCP 专用接口
4)sockaddr 结构体
5)IP 地址的表现形式
三、基于 UDP 协议的网络编程
1)服务端
.1- 创建套接字
.2- 绑定套接字
.3- 运行
2)客户端
.1- 创建套接字
.2- 运行
3)本地测试
4)网络测试
.1- 绑定INADDR_ANY
.2- 测试效果
5)完整代码
四、基于 TCP 协议的网络编程
1)服务端
.1- 创建套接字
.2- 绑定
.3- 监听
.4- 获取数据请求
.5- 处理数据请求
2)客户端
.1- 创建套接字
.2- 连接服务端
.3- 发送数据请求
3)单执行流服务端并不实用
4)多进程版本的服务端
.1- 捕捉信号版
.2- 爷孙进程版
5)多线程版本的服务端
6)线程池版本的服务端
.1- 多线程版本服务端的弊病
.2- 线程池的引入
.3- 测试效果
一、Socket 通信
1)源与目的系列
在广域网中进行通信,例如一个局域网内的一台主机要给另一台主机发送数据,离不开局域网之间的路由器。
总得来说,在广域网的通信过程大致为主机到路由器再到主机,但在实际中数据传输过程中,主机到主机之间的路由器可能有若干个,使得数据传输过程更加复杂,而其复杂主要在于如何确定数据传输的去向。
数据在传输过程中,一台主机到另一台主机这是确定的,也就是说,数据的最终去向是确定不变的;但数据并不会直接从一台主机传输到另一台主机,而是要在途中经由路由器,那么,一台主机到一台路由器、一台路由器到另一台路由器、最终到另一台主机,这个过程中数据的去向是始终在变化的。
为了确定数据在传输过程的去向,以更好地完成数据传输,前人引入了 IP 地址、MAC 地址、端口号等概念。
.1- 源 IP 地址 vs 目的 IP 地址
因特网上的每台计算机都有一个唯一的 IP 地址,主要用来表明数据的最终去向或最初来源。
如果有一台主机要向另一台主机传输数据,那么接收数据的主机的 IP 地址,就会在数据传输过程中被当作目的 IP 地址;但只知道目的 IP 地址是不够的,在接收数据的主机收到数据后,还会对发送数据的主机做出响应,具体方式是给其发送数据,因此也要知道发送数据的主机的IP地址,也就是源 IP 地址。
一份正在传输的数据中,会包含其源 IP 地址和目的 IP 地址,其中,目的 IP 地址用来表明数据的最终去向,而源 IP 地址作为对端主机响应时的目的 IP 地址。
数据在开始传输之前,会先被自顶向下地贯穿网络协议栈完成封装,在网络层封装的 IP 报头中,就包含了源 IP 地址和目的 IP 地址。
总得来说,数据的源 IP 地址和目的 IP 地址在数据传输之初就已确定,在传输过程中一般不会改变。
.2- 源 MAC 地址 vs 目的 MAC 地址
在广域网的通信中,数据在传输过程中要经过若干个路由器,才能到达对端主机。由于广域网是由若干个局域网组成的,那么在广域网的通信中,数据的传输势必涉及跨局域网。为了支持数据更好地在局域网之间传输,前人引入了 MAC 地址,主要用来表明数据的最近去向或最近来源。
在局域网中,每台计算机都绑定了一张网卡,因此每台计算机都有一个只属于自己的 MAC 地址,以表明自己在某个局域网中的唯一性。
跨局域网的通信,与路由器息息相关。一个路由器至少能够横跨两个局域网,而被路由器级联的局域网都认为,这个级联它们的路由器是自己(局域网)内部的一台主机,因此,路由器可以和这些局域网内的任意一台主机直接进行通信。
在跨局域网的通信中,一台主机向另一台主机传输的数据,并不是直接发送给另一台主机的,而要先经由路由器,再送到另一台主机。
在数据被主机发送给路由器的过程中,先会自顶向下地贯穿主机网络协议栈完成封装,尤其在主机的数据链路层中,封装上包含源 MAC 地址和目的 MAC 地址的报头,再被发送给路由器,其中,源 MAC 地址是主机自己的 MAC 地址,而目的 MAC 地址则是接收数据的路由器的 MAC 地址。
收到主机发来的数据后,路由器会先使数据自底向上地贯穿网络协议栈完成解包(从数据链路层到网络层),再使数据自顶向下地贯穿网络协议栈完成封装。同样的,在路由器的数据链路层中,封装上包含源 MAC 地址和目的 MAC 地址的报头,但此时的源 MAC 地址和目的 MAC 地址相较于路由器解包数据前,已经发生了变化,此时的源 MAC 地址,其实是当前路由器的 MAC 地址,而此时的目的 MAC 地址,其实变成了下一台要接收数据的路由器或主机的 MAC 地址。完成封装后,当前路由器会将数据发送给下一台要接收数据的路由器或主机。
总得来说,数据的源 MAC 地址和目的 MAC 地址会随着数据的传输,因路由器不断的解包和封装而不断发生变化。
.3- 源端口号 vs 目的端口号
通过 IP 地址和 MAC 地址,已经能够很好地完成数据传输,但在实际上的数据传输过程中,数据的传输并非在一台主机与另一台主机之间,更准确地说,数据的发送者是一台主机上的一个进程,或称客户进程(client),而数据的接收者是另一台对端主机上的一个进程,或称服务进程(server)。
在两台正在通信的主机上,可能同时存在多个正在进行跨网络通信的进程,因此,在数据到达服务端主机之后,必须要通过某种方法,找到该主机上对应的服务进程,并将数据交给服务进程处理;在服务进程处理完数据之后,还要对客户端进行响应,因此,服务端主机也需要知道,具体是客户端上的哪一个客户进程向它发送了数据请求。
而端口号就是用来标识一台主机上的一个进程的。
【Tips】端口号(port)的特征
- 端口号是传输层协议的内容。
- 端口号是一个 2 字节 16 位的整数。
- 端口号用于标识一个进程,以告知操作系统,当前数据要交由主机中的哪一个进程来处理。
- 一个进程可以同时绑定多个端口号,但一个端口号不能同时被多个进程绑定。
- 由于端口号是隶属于某台主机的,因此可以在两台不同的主机之间重复,但绝不能在同一台主机上进行网络通信的进程之间重复。
由于 IP 地址能够唯一地标识公网内的一台主机,端口号则能够唯一地标识一台主机上的一个进程,因此,“IP 地址 + 端口号”能够唯一地标识网络中的某一台主机的某一个进程。
当数据在传输层进行封装时,会被封装上对应的源端口号和目的端口号,此时,通过源 IP 地址和源端口号,就能够在网络中唯一地标识发送数据的进程;而通过目的 IP 地址和目的端口号,就能够在网络上唯一地标识接收数据的进程,以此实现跨网络的进程间通信。
【补】进程PID vs 端口号
进程 PID 用于标识系统内所有进程的唯一性,是属于系统级的概念;端口号则用于标识对外进行网络数据请求的进程的唯一性,是属于网络的概念。
它们本身都用于标识一个进程的唯一性。这就好比一个人在不同场合下有不同的身份信息,但这些信息都能指向这个人,只是发挥作用的场合有区别罢了。例如,在国家的行政管理下,这个人拥有仅属于自己的身份证号;在学校的管理下,这个人拥有仅属于自己的学号;或在公司的管理下,这个人拥有仅属于自己的工号。
在底层,实际上进程 PID 和端口号是采用哈希的方式建立了一种映射关系。当底层拿到一个端口号时,就可以通过对应的哈希算法,找到其所映射的进程 PID,进而找到其所对应的进程。
2)socket 与网络通信本质
socket 直译为中文有“插座”的意思。在现实生活中,只有插座上的一个插孔与一个插头的规格相适配,才能完成某种电流传输的需求,正如在进行网络通信时,一台客户端上的一个客户进程,能够通过“IP 地址 + 端口号”,适配地找到一台服务端上的一个服务进程,以完成数据的传输和处理。
而“IP 地址 + 端口号”,其实就是套接字 socket 。
网络通信,也就是 socket 通信,本质上就是进程间的通信。
【Tips】网络通信的本质:进程间通信。
- 要实现进程间通信,就必须存在共享资源,而网络恰恰就是一种共享资源。
- 进行网络通信,其实是在做 IO,因此所有上网的行为基本可以分为两种:①把数据发出去、 ②把数据读回来。
3)TCP 协议和 UDP 协议
网络协议栈是贯穿整个计算机体系结构的,在应用层、操作系统层和驱动层都有一部分,而协议栈的每一层都有对应的网络协议。在进行网络编程、使用系统调用接口实现网络数据通信时,一定会涉及传输层的协议,其中,最典型的两种即 TCP 协议和 UDP 协议。
【Tips】TCP协议(Transmission Control Protocol,传输控制协议)
- TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。
- TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,只有在连接建立成功后才能进行数据传输。
- TCP协议是保证可靠的协议,如果数据在传输过程中出现了丢包、乱序等情况,TCP协议都有对应的解决方法。
【Tips】UDP协议(User Datagram Protocol,用户数据报协议)
- UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。
- 使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了。
- UDP协议是不可靠的,如果数据在传输过程中出现了丢包、乱序等情况,UDP协议本身是无法得知的。
既然使用可靠的 TCP 协议,能够在一定程度上保证数据传输时的可靠性,那么不可靠的 UDP 协议的存在又有什么意义呢?
虽然 TCP 协议是一种可靠的传输协议,但为了维护所谓的可靠,它就需要在底层做更多的工作,因此 TCP 协议底层的实现是比较复杂的,数据的传输更稳定但过程更缓慢。
而 UDP 协议虽然是一种不可靠的传输协议,但它在底层并不需要做过多的工作,因此 UDP协议在底层的实现一定更加简单,数据的传输不稳定但过程更迅速。
进行网络编程时,要采用 TCP 协议还是 UDP 协议,具体取决于上层的应用场景。
如果应用场景严格要求数据在传输过程中的可靠性,就必须采用TCP协议;如果应用场景允许数据在传输过程中出现少量丢包,那么肯定优先选择 UDP 协议。
而一些优秀的网站在设计网络通信算法时,会同时采用 TCP 协议和 UDP 协议,并且动态地调整后台数据通信的算法——当网速流畅时,就使用UDP协议进行数据传输;而当网速缓慢时,就使用TCP协议进行数据传输。
4)网络字节序为大端
对于数据的存储,计算机中有大小端的概念:
- 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
- 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端和小端之分, 网络数据流同样有大端和小端之分。
在只在本地机器上运行的程序,无须考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,无论采用的是大端存储模式还是小端存储模式,数据的读写结果均相同。
但网络通信的程序,由于无法保证通信双方存储数据的方式相同,因此必须考虑大小端问题,否则服务端主机识别的数据可能与客服端发送的数据是不一致的。例如,客户端是小端机,服务端是大端机,客户端通过网络向服务端发送了一个小端地址 0x11223344,而在服务端上就被识别成了大端地址 0x44332211。
TCP/IP 协议规定,网络数据流均采用大端字节序,即低地址高字节,无论是大端机还是小端机,都必须按照 TCP/IP 协议规定的网络字节序来发送和接收数据。
对于客户端:
- 若客户端是小端,则先将数据转成大端,再发送到网络当中。
- 若客户端是大端,则可以直接进行发送。
而对于服务端:
- 若服务端是小端,则先将接收到数据转成大端,再进行数据识别。
- 若服务端是大端,则可以直接进行数据识别。
例如,客户端是小端机,服务端是大端机,客户端通过网络向服务端发送了一个小端地址 0x11223344,这个小端地址按TCP/IP 协议要求会先被转换成大端地址,使服务端能够直接进行数据识别,服务端上也能识别出 0x11223344。
【补】网络字节序与主机字节序的转换接口
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了四个接口,以实现网络字节序和主机字节序之间的转换。
#include <arpa/inet.h> // 主机序列转网络序列 uint32_t htonl(uint32_t hostlong); // 将主机上unsigned int类型的数据转换成对应网络字节序 uint16_t htons(uint16_t hostshort); // 将主机上unsigned short类型的数据转换成对应网络字节序 // 网络序列转主机序列 uint32_t ntohl(uint32_t netlong); // 将从网络中读取的unsigned int类型的数据转换成当前计算机字节序 uint16_t ntohs(uint16_t netshort); // 将从网络中读取的unsigned short类型的数据转换成当前计算机字节序
- 函数名中,h 表示 host,n 表示 network,l 表示 32 位长整数,s 表示1 6 位短整数。
- 如果主机是小端字节序,则这些函数会先将参数做相应的大小端转换然后返回。
- 如果主机是大端字节序,则这些函数将不做任何转换,将参数原封不动地返回。
【补】为什么网络字节序采用的是大端?
主机字节序一般采用的是小端,网络字节序采用的却是大端。
如果网络字节序采用小端的话,数据在传输过程中就不用进行大小端的转换了,但为什么不采用小端,而偏偏采用大端呢?
对此,有一种说法是,TCP 在 Unix 时代就有了,曾经的 Unix 机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,于是现在主流的机器都是小端机,然而协议已经不好再改了。
而另一种说法是,大端序更符合现代人的读写习惯,因此网络字节序采用大端。
二、Socket 系列接口
1)TCP/UDP 通用接口
.1- 创建套接字 socket()
#include<sys/types.h>
#include<sys/socket.h>
int socket(int domain, int type, int protocol);
功能:创建套接字,并在创建时指定一种通信协议以使用。
参数:1.domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。
该参数就相当于struct sockaddr结构的前16个位。
如果是本地通信就设置为AF_UNIX,
如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。
2.type:创建套接字时所需的服务类型。
例如面向字节流的网络通信 SOCK_STREAM(TCP 协议)、
面向用户数据报的网络通信 SOCK_DGRAM (UDP 协议)等。
3.protocol:用于指定具体的协议名的,比如指定 TCP 或者 UDP,
但根据前两个参数其实已经可以确定使用的协议,因此一般设置为 0 即可。
返回值:创建成功,返回一个int类型的值(其实就是一个文件描述符);失败返回-1,并设置合适的错误码。
网络协议栈是分层的,由 TCP/IP 分层模型,自顶向下依次是应用层、传输层、网络层和数据链路层。
一般程序员所写的代码都是用户级代码,也就是说,编写代码一般在应用层,因此调用的接口实际是属于下三层的。而下三层中的传输层和网络层的操作,都是在操作系统内完成的,也就意味着在应用层调用的接口,其实都叫做系统调用接口。
socket() 是被进程所调用的。socket() 会被写在一个程序中的编码中,当程序编码形成的可执行程序,也就是进程,且被 CPU 调度并执行到 socket() 时,会执行创建套接字的代码,也就是说,socket() 其实是被进程所调用的。
每一个进程在执行时,不仅会打开执行相关的文件,还会在系统层面上,拥有一个进程控制块 task_struct。在 task_struct 中有一个指针,指向一个名为 files_struct 的结构体,而在 files_struct 结构体中又有一个名为 fd_array 、类型为 struct file* 的指针数组,也就是文件描述符表。文件描述符的下标其实就是文件描述符,而表中的0、1、2 下标默认依次对应了标准输入流、标准输出流、标准错误流。
当调用 socket() 创建套接字时,其实相当于打开了一个“网络文件”,并在内核层面上就形成了一个对应的 struct file 结构体。该结构体的首地址会被填入到 fd_array 数组当中下标为 3 的位置上,同时被链入到调用 socket() 进程的文件双链表中,最终,3 号文件描述符就作为 socket() 的返回值被返回了。
每一个 struct file 结构体中包含了进程所打开的文件的各种信息,例如文件的属性、操作方法、文件缓冲区等。在内核中,文件的属性是由 struct inode 结构体来维护的;而文件的操作方法其实是一堆函数指针(read*、write*等)由 struct file_operations 结构体来维护。
文件缓冲区对于进程打开的普通文件来说,一般是磁盘,但对于打开的“网络文件”来说,其实就是网卡。
.2- 绑定端口号 bind()
#include<sys/types.h>
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能:在创建好套接字后,将套接字与网络绑定(关联)起来
参数:1. sockfd:待绑定的文件描述符(使用的其实是 socket() 返回的文件描述符)。
2.addr:输出型参数,是 struct sockaddr 结构体类型的指针,
表示网络相关的属性信息,包括协议家族、IP地址、端口号等。
3.addrlen:输入输出型参数,是 socklen_t 类型的变量,本质是 unsigned int 类型的32位变量,
用于表示 sockaddr 结构体大小的,单位是字节。
返回值:绑定成功返回 0;失败返回 -1,并设置合适的错误码。
光创建好套接字,也只是在系统层面上打开了一个文件,接下来还需要将文件与网络绑定起来,让操作系统知道,要将数据刷新到网卡而非磁盘。
在使用 bind() 进行绑定时,还需要将网络相关的属性信息填充到一个结构体,也就是sockaddr_in 中,然后 将sockaddr_in* 强转为 sockaddr*。就可以完成对 bind() 第二个参数的传参了。
sockaddr_in 属于系统级概念,是一种网络套接字,其中封装了许多字段。
//网络套接字
struct sockaddr_in {
short int sin_family; // 地址族,一般为AF_INET
unsigned short int sin_port; // 端口号,是一个16位的整数
struct in_addr sin_addr; // IP地址,是一个32位的整数
//... 剩下的字段一般不做处理
};
在绑定时将 sockaddr_in 进行传参,也就相当于将 IP 地址和端口号告诉相应的网络文件,之后就可以将网络文件中操作方法的指向(因为其本质是函数指针)改为对应网卡的操作方法,使得读写数据的操作对象为网卡,进而做到将文件和网络绑定起来。
2)UDP 专用接口
- 读取数据 recvfrom()
#include<sys/type.h>
#include<sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
功能:用于 UDP 协议服务端读取 UDP 协议客户端发送的数据
或客户端读取服务端响应的数据
参数:1.sockfd:指定待读取数据的文件描述符。
2.buf:指定读取数据的存储位置。
3.len:指定读取数据的字节数。
4.flags:指定数据的读取方式,一般设置为0(阻塞读取)。
5.src_addr:是一个结构体,表示对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
6.addrlen:输入输出型参数,
传参时表示 src_addr 的长度,
返回时表示读取到的 src_addr 的长度。
返回值:读取成功则返回实际读取到的字节数,失败则返回-1,并设置合适的错误码。
【ps】
1.由于UDP是不面向连接的,因此除了要获取数据,
还要获取对端网络相关的属性信息,包括 IP 地址、端口号等。
2.由于 recvfrom() 提供的参数也是struct sockaddr*类型,
因此在传参时需要先将 struct sockaddr_in* 强转为 struct sockaddr*。
- 发送数据 sendto()
#include<sys/type.h>
#include<sys/socket.h>
ssize_t sendto(int sockfd, const void *buf,
size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
功能:用于 UDP 协议客户端向 UDP 协议服务端发送(写入)数据,
或服务端向客户端响应数据
参数:1.sockfd:待写入数据的文件的文件描述符。
2.buf:待写入数据。
3.len:指定写入数据的字节数。
4.flags:指定数据的写入方式,一般设置为0,表示阻塞写入。
5.dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
6.addrlen:表明 dest_addr 结构体的长度。
返回值:写入成功则返回实际写入的字节数,失败返回-1,并设置合适的错误码。
【ps】
1.由于UDP是不面向连接的,因此除了要获取数据,
还要获取对端网络相关的属性信息,包括 IP 地址、端口号等。
2.由于 sendto() 提供的参数 dest_addr 也是struct sockaddr*类型,
因此在传参时需要先将 struct sockaddr_in* 强转为 struct sockaddr*。
3)TCP 专用接口
- 监听套接字 listen()
#include<sys/type.h>
#include<sys/socket.h>
int listen(int sockfd, int backlog);
功能:用于在 bind() 之后,服务端监听客户端的连接请求。
参数:1.sockfd: 需要设置为监听状态的套接字(文件描述符)。
2.backlog:全连接队列的最大长度。
如果有多个客户端同时发来连接请求,未被服务端处理的请求会放入连接队列,
该参数代表的就是全连接队列的最大长度,一般设置为5或10即可。
返回值:监听成功返回 0;失败返回-1,并设置合适的错误码。
- 接收连接请求 accept()
#include<sys/type.h>
#include<sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
功能:用于在 listen() 之后,TCP协议的服务端接收客户端的连接请求
参数:1.sockfd:特定的监听套接字,表明会从该监听套接字中获取连接请求。
2.addr:输出型参数,对端主机的网络信息,包括协议家族、IP地址、端口号等。
3.addrlen:输入输出型参数,
传参时表示待读取的addr结构体的长度,返回时表示实际读取到的addr结构体的长度
返回值:接收成功返回接收到的(客户端的)套接字;失败返回-1,并设置合适的错误码。
【ps】
1.用于为accept()获取到的连接请求提供服务的,其实是accept()返回的套接字。
而作为accept()参数的监听套接字只负责不断获取新的连接请求。
2.accept()在获取连接时可能会失败,但TCP服务端不会因为这种失败而退出,
因此在accept()获取失败后,应该继续获取。
3.如果要将获取的连接请求所对应的客户端,其中的IP地址和端口号信息进行输出,
需要调用inet_ntoa()将整数IP转换成字符串IP、调用ntohs()将端口号由网络序列转换成主机序列。
【补】socket() 与 accept() 中的文件描述符
最初由 socket() 创建的描述符用于服务端接收一台客户端的连接请求,不用于与客户端的通讯。而 accept() 返回的文件描述符用于服务端与某一台客户端的通讯。
比方说,在一家餐厅中,有正在店外接客的服务员,也有正在店内服务的服务员,而socket() 创建的描述符就好比在店外接客的服务员,accept() 返回的描述符就好比在店内服务的服务员。
负责店外接客的服务员,在接引完客人之后,会回到店门口继续接客,在接客的过程中,哪怕只有一个服务员也基本可以完成接客工作;而店内可能同时有多桌客人要用餐,于是就要有多名在店内服务的服务员来完成服务工作,且为了保证服务的可靠性,一名服务员以此只能服务一桌客人,直到这一桌客人离店才清理餐桌,然后等待下一桌客人光临。类似的, socket() 创建的描述符在接收了客户端的连接请求后,会继续等待下一台客户端的连接请求送达,而全程只有一个这样的描述符;accept() 返回的文件描述符负责服务端与某一台客户端的通讯,在通讯结束之后会被关闭,等待下一台要通讯的客户端。
- 数据请求相关 read()、write()
#include<sys/type.h>
#include<sys/socket.h>
ssize_t read(int fd, void *buf, size_t count);
功能:从accept()返回的套接字中读取数据,用于服务端读取客户端发来的数据、客户端向服务端回应数据。
参数:1.fd:待读取的文件描述符。
2.buf:将读取到的数据存储到 buf 所指的位置。
3.count:从 fd 中读取数据的字节数。
返回值:返回值 > 0 时,表示实际读取到的字节数。
返回值 = 0 时,表示对端(客户端)已关闭连接,服务端不再为其提供服务。
返回值 < 0 时,表示读取失败,并设置合适的错误码。
【ps】返回值为 0 表示对端已关闭连接
类似于本地进程间的管道通信————
· 写端不写会使数据未就绪,读端进程一直读,就会被挂起;
· 读端不读,写端一直写,待管道写满后就会被挂起;
· 写端写完后将写端关闭,读端就会读到 0;
· 读端将读端关闭,写端写入的数据不会被读取,写端就会被系统杀掉。
发来连接请求的客户端好比写端,接收连接请求的服务端好比读端,
客户端关闭连接,服务端就不必再为其提供服务了,对于 read() 返回值为 0。
#include<sys/type.h>
#include<sys/socket.h>
ssize_t write(int fd, const void *buf, size_t count);
功能:向accept()返回的套接字中写入数据,用于服务端向客户端回应数据、客户端向服务端发送数据。
参数:1.fd:待写入数据的文件描述符。
2.buf:指向待写入数据的指针。
3.count:指定待写入数据的字节数。
返回值:写入成功则返回实际写入的字节数;失败则返回-1,并设置合适的错误码。
- 建立连接 connect()
#include<sys/type.h>
#include<sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能:用于客户端与服务端建立连接。
参数:1.sockfd:发起连接请求的文件描述符。
2.addr:输出型参数,对端主机的网络信息,包括协议家族、IP地址、端口号等。。
3.addrlen:输入输出型参数,即 addr 的大小。
返回值:连接成功返回 0,失败返回 -1 并设置合适的错误码。
4)sockaddr 结构体
socket 其实有很多种类型,常见的有以下三种:
- 网络套接字:用户跨主机之间的通信,也能支持本地通信。
- 原始套接字:可以跨过传输层(TCP/UDP)访问底层的数据。
- 域间套接字:只能在本地通信。
它们的应用场景完全不同,因此不同种类的 socket 都各自对应了一套系统调用接口,也就是说,三套 socket 就会对应三套不同的接口。
socket 不仅支持跨网络的进程间通信,还支持本地的进程间通信,这是因为 socket 本身提供了支持跨网络通信的 sockaddr_in 结构体和支持本地通信的 sockaddr_un 结构体。
//网络套接字
struct sockaddr_in {
short int sin_family; // 地址族,一般为AF_INET
unsigned short int sin_port; // 端口号,网络字节序
struct in_addr sin_addr; // IP地址
unsigned char sin_zero[8]; // 用于填充,使sizeof(sockaddr_in)等于16
};
//域间套接字
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* 带有路径的文件名 */
};
为了在进行 socket 的网络通信和本地通信时,能够使用同一套函数接口,于是就出现了sockeaddr 结构体,虽然该结构体与 sockaddr_in 和 sockaddr_un 的结构都不相同,但这三个结构体头部的 16 个比特位都是相同的,而这 16 个比特位的字段叫做协议家族。
因此,在对相关的 socket 系统接口进行传参时,无须分情况传入 sockaddr_in 或 sockaddr_un,只需统一传入 sockeaddr 即可,且通过设置协议家族这个字段,就可以表面当前是要进行网络通信还是本地通信。
总得来说,可以将 sockaddr 看成是基类,把 sockaddr_in 和 sockaddr_un 看成是派生类。
不过,进行网络通信的编程时,还是会定义 sockaddr_in 这样的结构体,只是需要在传参时将 sockaddr_in* 强转为 sockaddr* 。
通过 sockaddr_in 结构体,将IP地址,端口号,以及网络通信AF_INET通过系统调用bind与系统绑定,从而进行网络通信。但在使用 sockaddr_in 结构体之前,需先用 bzero() 或 memset() 将其清零:
#include <strings.h>
void bzero(void *s, size_t n);
功能:只能清零
参数:1.s:待清零的地址
2.n:待清零的字节数
然后,将 sockaddr_in 结构体的地址类型成员 sin_family(协议簇)填充为 AF_INET 以表明要进行网络通信;在填充 sin_port(端口号)时,需要先使用 htons(),将主机字节序转换成网络字节序,然后再进行填充;而在填充 sin_addr(IP地址)时,需用到 inet_addr()。
5)IP 地址的表现形式
IP地址的表现形式有字符串和整数两种。
- 字符串 IP:或称基于字符串的点分十进制 IP 地址,例如 192.168.233.123,一个地址有 15 字节。
- 整数 IP:直接用一个 32 位的整数来表示 IP 地址,一个地址有 4 字节。
在网络通信时,直接使用字符串 IP,那么传输一个 IP 地址至少就需要 15 个字节,但实际并不需要耗费这么多空间。
这是因为,字符串 IP 可以划分为四个区域,其中每一个区域的取值都是 0~255,而 0~255,这个范围只需要用 8 个比特位就能表示,因此 32 个比特位其实就能够表示一个 IP 地址。这个 32位其实就是一个整数,整数每一个字节都对应了字符串 IP 中的某个区域,而 这这种表示 IP 地址的方法就称为整数 IP,此时只需要 4 个字节就可以传输一个 IP 地址。
整数 IP 和字符串 IP 都能表示同样的含义,而整数 IP 在传输时的数据量更小,因此,在网络通信中 IP 地址的表现形式,采用的是整数 IP 。
但字符串 IP 仍在用户之间被广泛使用,因此,在网络通信时也就一定会涉及字符串 IP 与整数 IP 之间的相互转换。
在操作系统内部,字符串 IP 与整数 IP 之间的相互转换是通过位段和枚举来实现的。
由于联合体的空间是成员共享的,因此设置IP和读取IP的方式可以如下:
- 要以整数 IP 的形式设置 IP 时,只需直接将其赋值给联合体的第一个成员。
- 要以字符串 IP 的形式设置 IP 时,只需先将字符串 IP 分成对应的四部分,然后将每部分转换成对应的二进制序列,并依次设置到联合体中第二个成员当中的p1、p2、p3、p4。
- 要取出整数 IP 时,只需直接读取联合体的第一个成员。
- 要取出字符串 IP 时,只需依次获取联合体第二个成员中的p1、p2、p3、p4,然后将每一部分转换成字符串且拼接到一起。
而操作系统向上层提供了一些接口,以支持网络编程中字符串 IP 与整数 IP 之间的相互转换。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
功能:将字符串 IP 转换成整数 IP
参数:待转换的字符串 IP
返回值:转换后的整数 IP
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr in);
功能:整数 IP 转换成字符串 IP
参数:待转换的整数 IP(实际是一个封装了字符串 IP 四个部分的结构体)
返回值:转换后的字符串 IP
三、基于 UDP 协议的网络编程
网络通信是双向的,一端是接收数据的服务端(Server),另一端是发送数据的客户端(Client)。
1)服务端
.1- 创建套接字
- udp_server.hpp
class UdpServer
{
public:
UdpServer(std::string ip, int port)
:_sockfd(-1)
//...
{};
bool InitServer()
{
//创建套接字
//使用 IPv4 的协议簇、使用 UDP 协议
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){
std::cerr << "socket error" << std::endl;//创建失败,打印提示信息
return false;
}
//创建成功,打印文件描述符( socket()的返回值 )
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
//...
return true;
}
//...
~UdpServer()
{
//对于合法的文件描述符,在析构时关闭其所对应的文件
if (_sockfd >= 0){
close(_sockfd);
}
};
private:
int _sockfd; //文件描述符
//...
};
- udp_server.cc
int main()
{
UdpServer* svr = new UdpServer();
svr->InitServer();
return 0;
}
进程成功创建套接字后,获取到的文件描述符就是 3,这是因为 0、1、2 已经被标准输入流、标准输出流、标准错误流占用了,当前未被利用且最小的文件描述符就是 3。
.2- 绑定套接字
- udp_server.hpp
class UdpServer
{
public:
UdpServer(std::string ip, int port)
:_sockfd(-1)
,_port(port)
,_ip(ip)
{};
bool InitServer()
{
//1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){ //创建套接字失败
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
//2.绑定套接字
//①先填充网络通信的相关信息
struct sockaddr_in local; //创建 sockaddr_in 对象
memset(&local, '\0', sizeof(local)); //清零
local.sin_family = AF_INET; //设置 sin_family 字段
local.sin_port = htons(_port); //用 htons() 设置 sin_port 字段
local.sin_addr.s_addr = inet_addr(_ip.c_str()); //用 inet_addr() 设置 s_addr 字段
//②开始绑定
if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){
std::cerr << "bind error" << std::endl;//绑定失败则打印提示信息
return false;
}
std::cout << "bind success" << std::endl; //绑定成功也打印提示信息,方便调试
return true;
}
//...
~UdpServer()
{
if (_sockfd >= 0){
close(_sockfd);
}
};
private:
int _sockfd; //文件描述符
int _port; //端口号
std::string _ip; //IP地址
};
.3- 运行
创建和绑定好套接字,服务端的初始化也就完成了,接下来就可以运行服务端了。
服务端之所以称为服务端,是因为它在运行之后永不退出,会周而复始地提供某种服务,因此,服务端其实执行的是一个死循环代码。
由于 UDP 协议是不面向连接的,因此在 UDP 服务端启动后,就可以直接读取客户端发来的数据。
- udp_server.hpp
#define SIZE 128
class UdpServer
{
public:
UdpServer(std::string ip, int port)
:_sockfd(-1)
,_port(port)
,_ip(ip)
{};
bool InitServer()
{
//1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){ //创建套接字失败
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
//2.绑定套接字
//①先填充网络通信的相关信息
struct sockaddr_in local; //创建 sockaddr_in 对象
memset(&local, '\0', sizeof(local)); //清零
local.sin_family = AF_INET; //设置 sin_family 字段
local.sin_port = htons(_port); //用 htons() 设置 sin_port 字段
local.sin_addr.s_addr = inet_addr(_ip.c_str()); //用 inet_addr() 设置 s_addr 字段
//②开始绑定
if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){
std::cerr << "bind error" << std::endl;//绑定失败则打印提示信息
return false;
}
std::cout << "bind success" << std::endl; //绑定成功也打印提示信息,方便调试
return true;
}
//3.运行服务端
void Start()
{
char buffer[SIZE]; //用于存放对端发送的数据
while(1){
struct sockaddr_in peer;//用于保存对端的网络信息
socklen_t len = sizeof(peer);
//服务端调用 recvfrom() 以读取客户端发送的数据
ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
//读取成功则打印对端主机的IP地址和端口号
if (size > 0){
buffer[size] = '\0';
int port = ntohs(peer.sin_port); //ntohs()可以将sockaddr_in的sin_port转化为人看得懂的端口号
std::string ip = inet_ntoa(peer.sin_addr);//inet_ntoa()可以将整数IP转换成字符串IP
//打印对端主机的IP地址和端口号
std::cout << ip << ":" << port << "# " << buffer << std::endl;
}
//读取失败则打印提示信息,但服务端不会退出
else{
std::cerr << "recvfrom error" << std::endl;
}
//只要收到数据,就向客户端响应
std::string echo_msg = "server get!->";
echo_msg += buffer;
sendto(_sockfd, echo_msg.c_str(), echo_msg.size(), 0, (struct sockaddr*)&peer, len);
}
}
~UdpServer()
{
if (_sockfd >= 0){
close(_sockfd);
}
};
private:
int _sockfd; //文件描述符
int _port; //端口号
std::string _ip; //IP地址
};
- udp_server.cc
int main(int argc, char* argv[]) //引入命令行参数,在服务端在运行时可以跟上IP地址和端口号
{
//错误处理
if (argc != 2){
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
//由云服务器的特殊性,服务端运行时其实无需跟上IP地址,仅跟端口号即可
//这里手动将IP地址设置为127.0.0.1(等价于localhost)表示本地主机,或称本地环回,这样方便测试
std::string ip = "127.0.0.1";
//单独获取端口号
int port = atoi(argv[1]);
//开始运行
UdpServer* svr = new UdpServer(ip, port);
svr->InitServer();
svr->Start();
return 0;
}
运行效果:
指令 netstat -nlup 可以查看对应网络相关的信息。
指令 netstat -lup 相比于指令 netstat -nlup ,用对应的域名服务器替换原本显示的IP地址。
2)客户端
【Tips】绑定的细节:服务端需绑定套接字,而客户端无需绑定套接字。
为了能在网络通信时找到通信的彼此,服务端和客户端都需要有各自的 IP 地址和端口号,只不过服务端需要绑定套接字,客户端则不需要。
为了能够接受服务端提供的服务,客户端就需要知道服务端的 IP 地址和端口号。通常来说,IP 地址对应的是域名,但端口号并没有显示指明过,因此服务端的端口号一定要是一个众所周知的端口号,且在选定后不能轻易改变,换句话说,服务端需要进行绑定,经绑定之后的端口号才真正属于自己。一个端口号只能被一个进程所绑定,服务端进行了绑定就独占了这个端口。
客户端虽然也需要端口号,但一般并不需要进行绑定,只需在访问服务端时,客户端的端口号是唯一的即可,无需与特定的客户端进程强相关。如果客户端绑定了某个端口号,那么这个端口号以后就只能给这一个客户端使用,而如果这个客户端没有启动,这个端口号也就无法分配给别人,另外,如果这个端口号被别人使用了,这个客户端也就无法启动了。因此,客户端的端口号只需要保证唯一性而无需进行绑定,这样,客户端的端口号可以动态地进行设置(一般是调用类似于 sendto() 这样的接口,操作系统会自动给当前客户端分配一个唯一的端口号)。也就是说,客户端每次启动时使用的端口号可能是不同的,且只要系统中的端口号没有被耗尽,客户端就永远可以启动。
.1- 创建套接字
- udp_client.hpp
class UdpClient
{
public:
UdpClient(std::string server_ip, int server_port)
:_sockfd(-1)
,_server_port(server_port)
,_server_ip(server_ip)
{}
bool InitClient()
{
//创建套接字
//使用 IPv4 的协议簇、使用 UDP 协议
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){
std::cerr << "socket create error" << std::endl;
return false;
}
return true;
}
//...
~UdpClient()
{
if (_sockfd >= 0){
close(_sockfd);
}
}
private:
int _sockfd; //文件描述符
int _server_port; //服务端端口号
std::string _server_ip; //服务端IP地址
};
.2- 运行
- udp_client.hpp
#define SIZE 128
class UdpClient
{
public:
UdpClient(std::string server_ip, int server_port)
:_sockfd(-1)
,_server_port(server_port)
,_server_ip(server_ip)
{}
//1.创建套接字
bool InitClient()
{
//使用 IPv4 的协议簇、使用 UDP 协议
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){
std::cerr << "socket create error" << std::endl;
return false;
}
return true;
}
//2.运行客户端
void Start()
{
std::string msg; //用于存储待发送的数据
struct sockaddr_in peer; //用于保存对端主机的网络信息
memset(&peer, '\0', sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(_server_port);
peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
while(1){
std::cout << "Please Enter# ";
//从键盘获取数据
getline(std::cin, msg);
//向客户端发送数据
sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
char buffer[SIZE];
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);
ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&tmp, &len);
if (size > 0){
buffer[size] = '\0';
std::cout << buffer << std::endl;
}
}
}
~UdpClient()
{
if (_sockfd >= 0){
close(_sockfd);
}
}
private:
int _sockfd; //文件描述符
int _server_port; //服务端端口号
std::string _server_ip; //服务端IP地址
};
- udp_client.cc
int main(int argc, char* argv[])
{
//错误处理
if (argc != 3){
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
//获取输入的IP地址和端口号
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
//开始运行
UdpClient* clt = new UdpClient(server_ip, server_port);
clt->InitClient();
clt->Start();
return 0;
}
- Makefile
.PHONY:all
all:udp_client udp_server
udp_client:udp_client.cc
g++ -o $@ $^ -std=c++11
udp_server:udp_server.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f udp_client udp_server
3)本地测试
当前服务端并没有绑定外网,而绑定的是本地环回 127.0.0.1,以便进行本地测试。
运行服务端时,只需指明端口号即可(这里为 8080) 。
运行客户端时,需指明 IP 地址为本地环回 127.0.0.1、服务端的端口号 8080。
输入指令 netstat -nlup 可以查看当前的查看网络信息。
4)网络测试
.1- 绑定INADDR_ANY
要进行网络测试,就要让服务端绑定云服务器的公网 IP(这里,小编的云服务器的公网 IP 为122.152.220.113)。
但由于云服务器的 IP 地址是由云厂商提供的,这个 IP 地址并不一定是真正的公网IP,因此是不能直接被绑定的,如果要让外网访问,就需要在设置 bind() 的参数,即填充 sockaddr_in 结构体时,将 sin_addr 字段设置为 INADDR_ANY 。
INADDR_ANY 本身是一个宏值,对应的值为 0,因此也不存在大小端的问题,在设置时可以不考虑网络字节序的转换。
【补】绑定 INADDR_ANY 的好处
当一台机器的带宽足够大时,它接收数据的能力同时也会约束它的 IO 效率,因此为了提升 IO 效率,一台机器底层可能装有多张网卡,同时可能会使它拥有有多个IP地址。
在一台机器的服务端上,端口号为 8080 的服务只有一个,但这个服务端在底层可能有多张与它相关的网卡,因此它在接收数据时,与它相关的多张网卡会都收到数据。
如果服务端在绑定时,是指明绑定的某一个具体的 IP 地址,也就是绑定了一张特定的网卡,那么服务端在接收客户端发来的数据时,就只能从特定的一张网卡中读取数据。
但如果服务端绑定的是 INADDR_ANY,那么只要是发送给端口号为 8080 的服务的数据,都可以被系统自底向上交给该服务端。
- udp_server.hpp
#define SIZE 128
class UdpServer
{
public:
UdpServer(std::string ip, int port)
:_sockfd(-1)
,_port(port)
,_ip(ip)
{};
bool InitServer()
{
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
//绑定套接字
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; //绑定INADDR_ANY
//②开始绑定
if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){
std::cerr << "bind error" << std::endl;
return false;
}
std::cout << "bind success" << std::endl;
return true;
}
void Start()
{
char buffer[SIZE];
while(1){
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if (size > 0){
buffer[size] = '\0';
int port = ntohs(peer.sin_port);
std::string ip = inet_ntoa(peer.sin_addr);
std::cout << ip << ":" << port << "# " << buffer << std::endl;
}
else{
std::cerr << "recvfrom error" << std::endl;
}
std::string echo_msg = "server get!->";
echo_msg += buffer;
sendto(_sockfd, echo_msg.c_str(), echo_msg.size(), 0, (struct sockaddr*)&peer, len);
}
}
~UdpServer()
{
if (_sockfd >= 0){
close(_sockfd);
}
};
private:
int _sockfd;
int _port;
std::string _ip;
};
用指令 netstat -nlup 查看网络信息后发现,服务端的本地 IP 地址从原本 127.0.0.1 变成了 0.0.0.0,这就意味着该 UDP 服务端现在可以在本地读取任何一张网卡的数据。
接下来,就可以进行网络测试了。
.2- 测试效果
先启动服务端,再将服务端的IP地址和端口号作为命令行参数去启动客户端,此时,客户端和服务端就可以进行网络通信了。
【补】分发客户端
使用指令 sz 将客户端可执行程序下载到本地机器,然后发送给其他人。
其他人收到客户端可执行程序后,可以通过指令 rz 或拖拽的方式,将其上传到自己的云服务器上,然后通过指令 chmod 命令为可执行程序加上可执行权限后即可正常运行。
(注:小编云服务器的系统是 CentOS 7 版本的 Linux)
5)完整代码
//udp_server.hpp
#include <iostream>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/socket.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SIZE 128
class UdpServer
{
public:
UdpServer(std::string ip, int port)
:_sockfd(-1)
,_port(port)
,_ip(ip)
{};
bool InitServer()
{
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
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;
if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){
std::cerr << "bind error" << std::endl;
return false;
}
std::cout << "bind success" << std::endl;
return true;
}
void Start(){
char buffer[SIZE];
while(1){
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if (size > 0){
buffer[size] = '\0';
int port = ntohs(peer.sin_port);
std::string ip = inet_ntoa(peer.sin_addr);
std::cout << ip << ":" << port << "# " << buffer << std::endl;
}
else{
std::cerr << "recvfrom error" << std::endl;
}
std::string echo_msg = "server get!->";
echo_msg += buffer;
sendto(_sockfd, echo_msg.c_str(), echo_msg.size(), 0, (struct sockaddr*)&peer, len);
}
}
~UdpServer()
{
if (_sockfd >= 0){
close(_sockfd);
}
};
private:
int _sockfd;
int _port=0;
std::string _ip="";
};
//udp_client.hpp
#include <iostream>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/socket.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SIZE 128
class UdpClient
{
public:
UdpClient(std::string server_ip, int server_port)
:_sockfd(-1)
,_server_port(server_port)
,_server_ip(server_ip)
{}
bool InitClient()
{
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){
std::cerr << "socket create error" << std::endl;
return false;
}
return true;
}
void Start()
{
std::string msg;
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(_server_port);
peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
while(1){
std::cout << "Please Enter# ";
getline(std::cin, msg);
sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
char buffer[SIZE];
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);
ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&tmp, &len);
if (size > 0){
buffer[size] = '\0';
std::cout << buffer << std::endl;
}
}
}
~UdpClient()
{
if (_sockfd >= 0){
close(_sockfd);
}
}
private:
int _sockfd;
int _server_port;
std::string _server_ip;
};
//udp_server.cc
#include "udp_server.hpp"
#include <memory>
#include <cstdlib>
int main(int argc, char* argv[])
{
if (argc != 2){
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
std::string ip = "127.0.0.1";
int port = atoi(argv[1]);
UdpServer* svr = new UdpServer(ip, port);
svr->InitServer();
svr->Start();
return 0;
}
//udp_client.cc
#include "udp_client.hpp"
#include <memory>
#include <cstdlib>
int main(int argc, char* argv[])
{
if (argc != 3){
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
UdpClient* clt = new UdpClient(server_ip, server_port);
clt->InitClient();
clt->Start();
return 0;
}
//Makefile
.PHONY:all
all:udp_client udp_server
udp_client:udp_client.cc
g++ -o $@ $^ -std=c++11
udp_server:udp_server.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f udp_client udp_server
四、基于 TCP 协议的网络编程
1)服务端
.1- 创建套接字
- tcp_server.hpp
class TcpServer
{
public:
TcpServer()
: _sock(-1)
//...
{}
void InitServer()
{
//创建套接字
//使用 IPv4 的协议簇、使用 TCP 协议
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//...
}
//...
~TcpServer()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _sock; //文件描述符
//...
};
.2- 绑定
- tcp_server.hpp
class TcpServer
{
public:
TcpServer(int port)
: _sock(-1)
, _port(port)
{}
void InitServer()
{
//1.创建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//2.绑定
//①先填充网络通信的相关信息
struct sockaddr_in local;
memset(&local, '\0', sizeof(local));
local.sin_family = AF_INET; //表明进行网络通信
local.sin_port = htons(_port); //用 htons() 设置端口号
local.sin_addr.s_addr = INADDR_ANY; //IP 地址绑定为 INADDR_ANY
//②开始绑定
if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
}
//...
~TcpServer()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _sock; //文件描述符
int _port; //端口号
};
.3- 监听
TCP 服务端在创建和绑定好套接字后,还需要将套接字设置为监听状态,以监听是否有新的客户端数据请求到来。
如果监听失败,也就意味着 TCP 服务端无法接收客户端发来的数据请求,此时就直接终止服务端程序即可。
- tcp_server.hpp
#define BACKLOG 5
class TcpServer
{
public:
TcpServer(int port)
: _listen_sock(-1)
, _port(port)
{}
void InitServer()
{
//1.创建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//2.绑定
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;
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
//3.监听
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
//...
~TcpServer()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _listen_sock; //待监听的套接字
int _port; //端口号
};
.4- 获取数据请求
服务端进入监听状态后,如果有客户端发来数据请求,就要对其进行获取操作。
- tcp_server.hpp
#define BACKLOG 5
class TcpServer
{
public:
TcpServer(int port)
: _listen_sock(-1)
, _port(port)
{}
void InitServer()
{
//1.创建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//2.绑定
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;
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
//3.监听
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
//4.运行
void Start()
{
while(1){
//(1)获取连接
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, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout<<"get a new link->"<<sock<<" ["<<client_ip<<"]:"<<client_port<<std::endl;
//...
}
}
private:
int _listen_sock; //待监听的套接字
int _port; //端口号
};
- tcp_server.cc
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]);
TcpServer* svr = new TcpServer(port);
svr->InitServer();
svr->Start();
return 0;
}
运行效果:
指令 netstat -nltp 可以查看相关的网络信息。
通过指令 telnet (需先安装,安装指令:yum -y install telnet telnet-server xinetd)向这个 TCP 服务端发送连接请求后,可以看到为其提供服务的的文件描述符为 4。
这是因为 0、1、2 已经对应了标准输入流、标准输出流、标准错误流,而 3 号文件描述符在初始化服务端时被分配给了监听套接字,因此,当第一个客户端发起连接请求时,为其提供服务的文件描述符为 4。
如果接下来还有其他客户端向这个 TCP 服务端发起连接请求,那么此时为其提供服务的文件描述符就为 5。
.5- 处理数据请求
服务端获取到客户端的数据请求后,就要对客户端的数据请求进行处理。
但注意,此时为客户端提供服务的并不是监听套接字,而是 accept() 返回的服务套接字。服务端读取数据是从服务套接字中读取的,而写入数据也是写入到服务套接字的,体现了 TCP 协议全双工的通信的特点。
- tcp_server.hpp
#define BACKLOG 5
class TcpServer
{
public:
TcpServer(int port)
: _listen_sock(-1)
, _port(port)
{}
void InitServer()
{
//1.创建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//2.绑定
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;
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
//3.监听
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
//4.运行
void Service();
void Start()
{
while(1){
//(1)获取连接请求
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, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout<<"get a new link->"<<sock<<" ["<<client_ip<<"]:"<<client_port<<std::endl;
//(2)处理连接请求
Service(sock, client_ip, client_port);
}
}
void Service(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
//读取成功,打印读取的数据,并向对端回应数据,然后继续读取
if (size > 0){
buffer[size] = '\0';
std::cout <<client_ip << " : " << client_port << "# " << buffer <<std::endl;
write(sock, buffer, size);
}
//对端关闭连接就不再为其提供服务
else if (size == 0){
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
//读取失败,打印提示信息,并结束读取
else{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
//读取完毕后,关闭对应的文件
close(sock);
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
private:
int _listen_sock; //待监听的套接字
int _port; //端口号
};
2)客户端
【ps】客户端无需进行绑定和监听。
.1- 创建套接字
- tcp_client.hpp
class TcpClient
{
public:
TcpClient(std::string server_ip, int server_port)
: _sock(-1)
, _server_ip(server_ip)
, _server_port(server_port)
{}
void InitClient()
{
//创建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//...
}
//...
~TcpClient()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _sock; //文件描述符
std::string _server_ip; //服务端的IP地址
int _server_port; //服务端的端口号
//客户端必须要知道,它要连接的服务端的IP地址和端口号,才能与特定的服务端进行通信
};
.2- 连接服务端
- tcp_client.hpp
class TcpClient
{
public:
TcpClient(std::string server_ip, int server_port)
: _sock(-1)
, _server_ip(server_ip)
, _server_port(server_port)
{}
void InitClient()
{
//1.创建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
}
//2.运行
void Start()
{
//(1)连接服务端
//设置对端(服务端)的网络信息
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(_server_port);
peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
//调用connect()与服务端建立连接
//连接成功,则向服务端发送数据请求
if (connect(_sock, (struct sockaddr*)&peer, sizeof(peer)) == 0){ //connect success
std::cout << "connect success..." << std::endl;
//(2)发送数据请求
//...
}
//连接失败,则打印提示信息并退出
else{
std::cerr << "connect failed..." << std::endl;
exit(3);
}
}
~TcpClient()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _sock; //文件描述符
std::string _server_ip; //服务端的IP地址
int _server_port; //服务端的端口号
//客户端必须要知道,它要连接的服务端的IP地址和端口号,
//才能通过connect()与特定的服务端进行通信
};
.3- 发送数据请求
- tcp_client.hpp
class TcpClient
{
public:
TcpClient(std::string server_ip, int server_port)
: _sock(-1)
, _server_ip(server_ip)
, _server_port(server_port)
{}
void InitClient()
{
//1.创建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
}
//2.运行
void Request();
void Start()
{
//(1)连接服务端
//设置对端(服务端)的网络信息
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(_server_port);
peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
//调用connect()与服务端建立连接
//连接成功,则向服务端发送数据请求
if (connect(_sock, (struct sockaddr*)&peer, sizeof(peer)) == 0){ //connect success
std::cout << "connect success..." << std::endl;
//(2)发送数据请求
Request();
}
//连接失败,则打印提示信息并退出
else{
std::cerr << "connect failed..." << std::endl;
exit(3);
}
}
void Request()
{
std::string msg;
char buffer[1024];
while (1){
std::cout << "Please Enter# ";
getline(std::cin, msg);
//向文件描述符/套接字中写入数据,以发送给服务端
write(_sock, msg.c_str(), msg.size());
//读取服务端的响应数据
ssize_t size = read(_sock, buffer, sizeof(buffer)-1);
if (size > 0){
buffer[size] = '\0';
std::cout << "server echo# " << buffer << std::endl;
}
else if (size == 0){
std::cout << "server close!" << std::endl;
break;
}
else{
std::cerr << "read error!" << std::endl;
break;
}
}
}
~TcpClient()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _sock; //文件描述符
std::string _server_ip; //服务端的IP地址
int _server_port; //服务端的端口号
//客户端必须要知道,它要连接的服务端的IP地址和端口号,
//才能通过connect()与特定的服务端进行通信
};
- tcp_client.cc
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << "server_ip server_port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3){
Usage(argv[0]);
exit(1);
}
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
TcpClient* clt = new TcpClient(server_ip, server_port);
clt->InitClient();
clt->Start();
return 0;
}
服务端与客户端的运行效果:
先启动服务端,再启动客户端。客户端能够向服务端发送数据和接收服务端的响应数据,服务端能够接收客户端发送的数据和向客户端响应数据。
客户端退出时,服务端停止为其提供服务,但不会退出。
3)单执行流服务端并不实用
以上服务端被实现为单执行流的。
仅用一个客户端连接一个单执行流服务端时,这一个客户端能够正常享受到服务端的服务。但有多个客户端要一个连接单执行流服务端时,单执行流服务端就无法正常为它们提供服务了。
一个客户端先连接上单执行流服务端,服务端能正常为其提供服务。
但另一个客户端也上单执行流服务端,这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。
只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。
服务端调用 accept() 获取数据请求后,为发送请求的客户端提供服务,但由于当前服务端是单执行流的,只能服务完当前客户端后,才能继续服务下一个客户端。
在服务端为第一个客户端提供服务期间,第二个客户端向服务端发起的数据请求时是成功的,但服务端并没有调用 accept() 获取它的数据请求,于是服务端就没有显示连接成功。
在底层,系统维护了一个连接队列,服务端没有获取的新数据请求,会被放到这个连接队列中,因此,虽然服务端没有获取第二个客户端发来的请求,但第二个客户端会显示连接成功。
单执行流服务端一段时间内只能为一个客户端提供服务,这样,服务端的资源既没有充分利用,也不符合实际的可能有多个客户端要连接服务端的情景,因此,服务端一般不会被实现为单执行流的,而要实现为多执行流的。
4)多进程版本的服务端
服务端为客户端提供服务的过程,可以由父子进程来共同完成,可以由父进程负责获取客户端的数据请求,获取到后可以创建子进程,由子进程进一步去为客户端提供服务。
- 创建的子进程会继承父进程的文件描述符表。
如果父进程已经打开了一个文件,其文件描述符是 3,此时父进程创建的子进程的 3 号文件描述符也会指向这个已经打开的文件,而如果子进程继续创建了一个子进程,也就是孙子进程,那么孙子进程的 3 号文件描述符同样也会指向这个已经打开的文件。但父子进程之间始终会保持独立性,父进程的文件描述符表所发生的变化,并不会影响到子进程继承的文件描述符表。
- 一般来说,父进程需要等待子进程退出,以免子进程变成僵尸进程。
一般父进程创建出子进程后,还需调用 wait() 或 waitpid() 等待子进程退出。
等待的方式一般有两种:阻塞式等待和非阻塞轮询。
如果服务端采用阻塞式等待,那就与单执行流版本一样,仍然要等待服务完当前客户端,才能继续获取下一个数据请求。
如果服务端采用非阻塞轮询,虽然在子进程为客户端提供服务期间,服务端可以继续获取新的数据请求,但还需要将所有子进程的PID保存下来,同时不停地检测子进程的退出状况。
总得来说,父进程要等待子进程退出,无论采用什么等待方式,服务端的功能都显得不尽人意,还不如让父进程别等待子进程退出了。
- 父进程不等待子进程退出
让父进程不等待子进程退出,也有两种常见方式:
一种则是,捕捉子进程的 SIGCHLD 信号,将信号的处理动作设置为忽略。
另一种则是,在父进程创建子进程后,子进程再创建孙子进程,让孙子进程去为客户端提供服务。
.1- 捕捉信号版
子进程退出时会给父进程发送 SIGCHLD 信号,如果父进程将 SIGCHLD 信号进行捕捉,并将信号的处理动作设置为忽略,就可以专心处理自己的工作,而无需关心子进程的退出情况了。
- tcp_server.hpp
#include <iostream>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/socket.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#define BACKLOG 5
class TcpServer
{
public:
TcpServer(int port)
: _listen_sock(-1)
, _port(port)
{}
void InitServer()
{
//1.创建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//2.绑定
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;
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
//3.监听
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
//4.运行
void Service();
void Start()
{
//忽略SIGCHLD信号
signal(SIGCHLD, SIG_IGN);
while(1){
//父进程负责不断获取客户端的数据请求
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, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
//父进程获取成功,则创建子进程去提供服务
pid_t id = fork();
if (id == 0){ //child
Service(sock, client_ip, client_port);
//子进程提供完服务就退出
exit(0);
}
}
}
void Service(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){
buffer[size] = '\0';
std::cout <<client_ip << " : " << client_port << "# " << buffer <<std::endl;
write(sock, buffer, size);
}
else if (size == 0){
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock);
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
private:
int _listen_sock; //待监听的套接字
int _port; //端口号
};
【补】进程监控脚本:
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
服务端与客户端运行效果:
①客户端 1 运行并向服务端发送消息。
②客户端 2 运行并向服务端发送消息。
③客户端 2 退出。
④客户端 1 退出。
.2- 爷孙进程版
让爷爷进程创建的爸爸进程,再创建出孙子进程,可以让孙子进程去为客户端提供服务, 也不用等待孙子进程退出。
具体的实现方式是,让爷爷进程在创建爸爸进程后等待爸爸进程退出,并让爸爸进程在创建完孙子进程后立刻退出,这样一来,爷爷进程就能立刻等待成功,继续获取其他客户端的数据请求。孙子进程会因爸爸进程的立即退出而变成孤儿进程,进而会被系统领养,之后就由系统来回收孙子进程的退出信息,爷爷进程也无需关心孙子进程的退出情况。
但要注意的是,爸爸进程会继承爷爷进程的监听套接字,但爸爸进程继承的监听套接字不会被用到,因此最好将其关闭,以免后续对监听套接字中的数据造成影响。同理,孙子进程为客户端提供服务,会用到爷爷进程获取到的套接字,即 accept() 的返回值,因此,在服务结束后,需关闭这个套接字,以免浪费资源和影响之后系统分配文件描述符。
【Tips】爷孙进程版的实现要点
- 爷爷进程:负责调用accept()获取客户端数据请求、创建爸爸进程、关闭accept() 返回的套接字。
- 爸爸进程:负责创建孙子进程、关闭继承的监听套接字。
- 孙子进程:负责为客户端提供服务。
- tcp_server.hpp
#include <iostream>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/socket.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<sys/types.h>
#include<sys/wait.h>
#define BACKLOG 5
class TcpServer
{
public:
TcpServer(int port)
: _listen_sock(-1)
, _port(port)
{}
void InitServer()
{
//1.创建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//2.绑定
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;
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
//3.监听
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
//4.运行
void Service();
void Start()
{
while(1){
//爷爷进程负责不断获取客户端的数据请求
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, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
//创建爸爸进程
pid_t id = fork();
if (id == 0){
//需关闭爸爸进程继承的监听套接字
close(_listen_sock);
//爸爸进程负责创建孙子进程并直接退出
if (fork() > 0){
exit(0);
}
//孙子进程用accept()返回的套接字,为客户端提供服务,并在提供完毕后退出
Service(sock, client_ip, client_port);
exit(0);
}
//爷爷进程关闭accept()返回的套接字
close(sock);
//爷爷进程等待爸爸进程退出(会立刻等待成功)
waitpid(id, nullptr, 0);
}
}
void Service(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){
buffer[size] = '\0';
std::cout <<client_ip << " : " << client_port << "# " << buffer <<std::endl;
write(sock, buffer, size);
}
else if (size == 0){
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock);
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
private:
int _listen_sock; //待监听的套接字
int _port; //端口号
};
服务端和客户端的运行效果:
①客户端 1 运行并向服务端发送消息(ps:PID为1的即是孤儿进程)。
②客户端 2 运行并向服务端发送消息。
③客户端 2 退出。
④客户端 1 退出。
5)多线程版本的服务端
进程的创建伴随着进程控制块、进程地址空间、页表等数据结构的创建,而线程本质是在一个进程进程地址空间内运行,线程的创建会共享一个进程的大部分资源,因此创建线程的成本比创建进程的成本要小得多。
服务端为客户端提供服务的过程,也可以由线程来完成。服务端的代码在运行后会生成一个进程,作为主要的执行流,负责不断获取客户端的数据请求,而这个主执行流,也就是主线程,之后可以创建子线程,让子线程去负责为客户端提供服务。
类似的,主线程在创建出子线程后,也需要等待子线程退出,以免造成像僵尸进程一样的内存泄漏问题。当然,也可以进行线程分离,让系统自动回收子线程所的资源,从而让主线程不必关心子线程的退出情况,可以专心做自己的事。
- 属于同一个进程的所有线程会共享同一张文件描述符表
文件描述符表维护的是进程与文件之间的对应关系,一个进程对应了一张文件描述符表。而主线程创建的子线程属于同一个进程,因此并不会为任何子线程创建独立的文件描述符表,属于同一个进程的所有线程会共享同一张文件描述符表。
需注意,虽然主线程调用 accept() 获取到的套接字,其他子线程也能直接访问,但子线程并不知道它所服务的客户端对应的是哪一个套接字,因此,主线程在创建在子线程后,还需要告诉子线程它要访问的文件描述符的值。
- 套接字如何关闭?
accept() 返回的套接字不能由主线程来关闭,而要在子线程为客户端提供完服务之后,由子线程来关闭。
对于监听套接字,子线程无需关心也就无需关闭它,否则主线程就无法从监听套接字中获取新的数据请求了。
- Service() 与 pthread_create() 要匹配
Service() 是上文中负责为客户端提供服务的接口,pthread_create() 则负责创建子线程。
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
功能:在进程的主线程中创建一个新的子线程
参数:1.thread:输出型参数,是一个 pthread_t 类型变量的地址,用于获取创建成功的线程的TID。
2.attr:用于设置线程的属性,不关心设为 NULL(默认属性)即可。
3.stat_routine:一个返回类型为void* 、参数为 void*的函数指针,即线程启动后要执行的函数(线程例程)。
4.arg,是传给 start_routine (线程例程)的参数,不关心设置为 NULL(默认方法)即可。
返回值:创建成功则返回0;失败则返回错误码
也就是说, Service() 其实就是子线程的例程,要作为第三个参数传给 pthread_create()。
线程例程的参数是由 pthread_create() 负责的,但它只有一个且为 void* 类型,而上文中 Service() 所需的参数有三个,即客户端对应的套接字、IP 地址、端口号,因此就需要将它们匹配起来。
具体的实现是,Service() 所需的三个参数封装在一个 Param 结构体中,主线程在创建子线程时,可以定义一个 Param 对象,将客户端的套接字、IP 地址、端口号设置在这个 Param 对象当中,然后将这个 Param 对象的地址从 Param* 类型强制为void* 类型,并传参到线程例程中,最终在线程例程中调用 Service()。
class Param
{
public:
Param(int sock, std::string ip, int port)
: _sock(sock)
, _ip(ip)
, _port(port)
{}
~Param()
{}
public:
int _sock;
std::string _ip;
int _port;
};
但要注意的是,pthread_create() 所需的线程例程,是一个参数类型为 void* 、返回类型为 void* 的函数,如果想将其定义在 TcpServer 类中,就得将其定义为静态成员函数,否则这个线程例程的第一个参数是隐藏的 this 指针,与 pthread_create() 的参数要求不匹配;另外,还需要将 Service() 其定义为静态成员函数,否则身为静态成员函数的线程例程无法调用还是非静态成员函数的 Service()。
- tcp_server.hpp
#include <iostream>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/socket.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<sys/types.h>
#include <pthread.h>
#define BACKLOG 5
//封装Service()参数的结构体
class Param
{
public:
Param(int sock, std::string ip, int port)
: _sock(sock)
, _ip(ip)
, _port(port)
{}
~Param()
{}
public:
int _sock;
std::string _ip;
int _port;
};
//服务端
class TcpServer
{
public:
TcpServer(int port)
: _listen_sock(-1)
, _port(port)
{}
void InitServer()
{
//1.创建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//2.绑定
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;
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
//3.监听
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
//4.运行
static void Service(int sock, std::string client_ip, int client_port);
static void* HandlerRequest(void* arg);
void Start()
{
while(1){
//主线程负责获取客户端的数据请求
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, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
//创建子线程,让子线程去为客户端提供服务
Param* p = new Param(sock, client_ip, client_port); //定义封装了Service()参数的结构体对象
pthread_t tid; //定义子线程的TID
pthread_create(&tid, nullptr, HandlerRequest, p); //创建子线程
}
}
//线程例程
static void* HandlerRequest(void* arg)
{
//分离子线程,让系统负责回收子线程资源
pthread_detach(pthread_self());
//获取Service()的参数结构体
Param* p = (Param*)arg;
//在线程例程中调用Service()为客户端提供服务
Service(p->_sock, p->_ip, p->_port);
//最终释放参数占用的堆空间
delete p;
return nullptr;
}
//负责提供服务的接口
static void Service(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (true){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){
buffer[size] = '\0';
std::cout <<client_ip << " : " << client_port << "# " << buffer <<std::endl;
write(sock, buffer, size);
}
else if (size == 0){
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock);
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
private:
int _listen_sock; //待监听的套接字
int _port; //端口号
};
- Makefile
.PHONY:all
all: tcp_client tcp_server
tcp_client:tcp_client.cc
g++ -o $@ $^ -std=c++11
tcp_server:tcp_server.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f tcp_client tcp_server
【补】线程监控脚本
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
服务端和客户端的运行效果:
①客户端 1 运行并向服务端发送消息。
②客户端 2 运行并向服务端发送消息。
③客户端 2 退出。
④客户端 1 退出。
6)线程池版本的服务端
.1- 多线程版本服务端的弊病
每当有新的数据请求到来时,多线程版本服务端的主线程都会重新为发送请求的客户端创建子线程,而服务结束后又会将子线程销毁。如果有大量的数据请求,服务端要为每一个发送请求的客户端创建一个子线程,计算机中的线程越多,CPU 的压力就越大,调度线程的成本也会很高,此时每一个线程再次被调度的周期会相应变长,客户端也可能会迟迟得不到应答。
总得来说,多线程版本服务端提供服务的过程,不仅麻烦还效率低下。
想要继续在此基础上继续优化服务端,可以在服务端中引入线程池。
大致的实现是,先在服务端内预先创建一批线程,创建的数量不必太多,CPU 的压力也不会太大,只要服务端获取到数据请求,就让这些线程为客户端提供服务,使客户端一来就有线程为其提供服务,无需等到客户端来了才创建线程。预先创建的线程为客户端提供完服务后,也不要让其退出,而让其继续为下一个客户端提供服务。
如果当前没有数据请求,还可以让线程们进入休眠状态,等到有数据请求到来时再将它们唤醒。
如果有数据请求到来,但预先创建的线程都在给其他客户端提供服务,此时也不必再创建线程,而让这个新到来的请求在全连接队列中排队,等有空闲的线程资源后,再为其提供服务。
.2- 线程池的引入
在服务端提供服务的过程中,服务端每接收一个客户端发来的数据请求,就要为相应的客户端提供服务,而为相应的客户端提供服务,这个过程可以看作是一个任务。
现设计一个 Task 类,其中封装了客户端对应的套接字、IP地址、端口号,以表明一个任务对象是为哪一个客户端提供服务、相应的套接字是哪一个。
此外,Task 类中还需要有任务的处理方法作为类成员,以便为客户端提供服务。
此处再设计一个 Handler 类,在 Handler 类中包含了 ( ) 操作符的重载函数,其函数体被定义为与上文中Service() 相同的内容,这样就能以仿函数的方式被封装进 Task 类中。
- ThreadPool.hpp
class Handler //Service()
{
public:
Handler()
{}
~Handler()
{}
void operator()(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){
buffer[size] = '\0';
std::cout << client_ip << ":" << client_port << "# " << buffer << std::endl;
write(sock, buffer, size);
}
else if (size == 0){
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock);
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
};
class Task //任务
{
public:
//无参构造
Task()
{}
//带参构造
Task(int sock, std::string client_ip, int client_port)
: _sock(sock)
, _client_ip(client_ip)
, _client_port(client_port)
{}
//析构
~Task()
{}
//为客户端提供服务的接口
void Run()
{
_handler(_sock, _client_ip, _client_port); //实例化 Handler 对象
}
private:
int _sock; //套接字
std::string _client_ip; //IP地址
int _client_port; //端口号
Handler _handler; //处理方法,实际是一个仿函数
};
接下来,实现线程池。
服务端收到的数据请求后,会构造任务对象,并将任务对象加入到被封装在线程池中的任务队列,以待处理。
当任务队列中有任务时,线程池中的线程会先定义出一个任务对象,用这个任务对象将任务队列的队首元素保存起来(这也是 Task 类既提供了带参构造还提供了一个无参构造的原因,方便定义无参对象),然后对任务队列进行出队操作,以此完成从任务队列中获取任务。
- ThreadPool.hpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <queue>
#include <pthread.h>
#include <functional>
#define NUM 5 //默认池中的线程数量为 5
//Service()
class Handler
{
public:
Handler()
{}
~Handler()
{}
void operator()(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){
buffer[size] = '\0';
std::cout << client_ip << ":" << client_port << "# " << buffer << std::endl;
write(sock, buffer, size);
}
else if (size == 0){
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock);
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
};
//任务
class Task
{
public:
Task()
{}
Task(int sock, std::string client_ip, int client_port)
: _sock(sock)
, _client_ip(client_ip)
, _client_port(client_port)
{}
~Task()
{}
void Run()
{
_handler(_sock, _client_ip, _client_port); //实例化 Handler 对象
}
private:
int _sock; //套接字
std::string _client_ip; //IP地址
int _client_port; //端口号
Handler _handler; //处理方法,实际是一个仿函数
};
//线程池
template<class T>
class ThreadPool
{
private:
//对任务队列验空
bool IsEmpty()
{
return _task_queue.size() == 0;
}
//加锁
void LockQueue()
{
pthread_mutex_lock(&_mutex);
}
//解锁
void UnLockQueue()
{
pthread_mutex_unlock(&_mutex);
}
//挂起(休眠)
void Wait()
{
pthread_cond_wait(&_cond, &_mutex);
}
//唤醒
void WakeUp()
{
pthread_cond_signal(&_cond);
}
public:
//构造
ThreadPool(int num = NUM)
: _thread_num(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
//析构
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
//线程例程
static void* Routine(void* arg)
{
//线程分离
pthread_detach(pthread_self());
//不断从任务队列获取任务进行处理
ThreadPool* self = (ThreadPool*)arg;
while (1){
self->LockQueue(); //加锁
while (self->IsEmpty()){
self->Wait(); //任务队列为空,就让线程休眠,等有任务再被唤醒
}
T task; //定义任务对象
self->Pop(task); //任务出队
self->UnLockQueue();//解锁
task.Run(); //处理任务,即提供服务
}
}
//初始化线程池(创建线程)
void ThreadPoolInit()
{
pthread_t tid;
for (int i = 0; i < _thread_num; i++){
pthread_create(&tid, nullptr, Routine, this); //向线程例程传入this指针,传入的其实是线程自己
}
}
//将任务入队
void Push(const T& task)
{
LockQueue(); //加锁
_task_queue.push(task); //任务入队
UnLockQueue(); //解锁
WakeUp(); //唤醒线程
}
//获取任务(出队)
void Pop(T& task)
{
task = _task_queue.front();
_task_queue.pop();
}
private:
std::queue<T> _task_queue; //任务队列
int _thread_num; //池中线程的数量
pthread_mutex_t _mutex; //互斥锁
pthread_cond_t _cond; //条件变量
};
引入线程池和任务类后,还需在服务端的 TcpServer 类中新增一个线程池成员。
这其实构建了一个生产者消费者模型,其中,服务端的进程是任务的生产者,会不断通过 TcpServer 类中的线程池成员创建任务并将任务加入到任务队列;后端线程池中的若干线程是任务的消费者,会不断从任务队列中获取任务并进行处理;而生产者和消费者的交易场所,就是线程池中的任务队列。
- tcp_server.hpp
#include <iostream>
#include <unordered_map>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/socket.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<sys/types.h>
#include<sys/wait.h>
#include"ThreadPool.hpp"
#define BACKLOG 5
class TcpServer
{
public:
TcpServer(int port)
: _listen_sock(-1)
, _port(port)
, _tp(nullptr)//线程池指针初始化为空,等到服务端正式运行后再构造线程池对象
{}
void InitServer()
{
//1.创建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//2.绑定
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;
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
//3.监听
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
_tp = new ThreadPool<Task>(); //构造线程池对象
}
//4.运行
void Start()
{
//初始化线程池(创建线程)
_tp->ThreadPoolInit();
while(1){
//(1)获取数据请求
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, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
//(2)处理数据请求
Task task(sock, client_ip, client_port); //构造任务
_tp->Push(task); //将任务入队进任务队列
}
}
private:
int _listen_sock; //监听套接字
int _port; //端口号
ThreadPool<Task>* _tp; //指向线程池的指针
};
.3- 测试效果
服务端和客户端的运行效果:
①服务端运行后。
②客户端 1 运行并向服务端发送消息。
③客户端 2 运行并向服务端发送消息。
④客户端 2 退出。
⑤客户端 1 退出。