赞
踩
因特网是在网络级进行互联的,因此,因特网在网络层(IP 层)完成地址的统一工作,把不同物理网络的地址统一到具有全球惟一性的 IP地址上,IP 层所用到的地址叫作因特网地址,又叫 IP 地址。
因特网采用一种全局通用的地址格式,为每一台主机都分配一个 IP 地址,以此屏蔽物理网络地址的差异,即 IP 地址的意义就是标识公网内唯一一台主机。
在 IP 数据包中的信息带有源 IP 地址和目的 IP 地址,它们分别标识通信的源结点和目的结点,即信源和信宿。IP 数据包经由路由转发的时候,源 IP 和目的 IP 不会改变,除非做了 NAT 转换才能改变。
网络通信的本质是进程间通信,有了 IP 就可以标识公网内唯一的一台主机,想要完成网络通信我们还需要一个东西来标识一台主机上的某个进程,这个标识就是端口号(port)。
端口号是传输层协议的内容,它包括如下几个特点:
理解 “端口号” 和 “进程 ID”
我们之前在学习操作系统的时候,知道 pid 可以用来标识进程;此处我们的端口号也是唯一标识一个进程。那么这两者之间又存在怎样的关系呢?
二者的相同点都是唯一标识主机内的一个进程,区别在于 pid 强调的是在系统范围内标识进程;而端口号强调在网络的范围内去标识进程。
既然 pid 已经做到唯一标识一个进程,为何还要引入端口号呢?我们可以从生活的角度去理解这种情况:即然每个人都有了唯一标识自己的身份照号,为何学校还要给我们分配学号呢?直接用身份照号不行吗?
学校给学生引入学号后,除了唯一标识学生这个作用外还有另外两个优点:
源端口号和目的端口号
对应到网络层协议的源 IP 和目的 IP,传输层协议(TCP 和 UDP)的数据段中也有两个端口号, 分别叫做源端口号和目的端口号.,它们描述 “数据是那个进程发送的, 要发给另外那个进程”。
socket 通信的本质就是跨网络的进程间通信,任何的网络客户端和网络服务如果要进行正常的数据通信,它们必须要有自己的端口号和匹配所属主机的 IP 地址。
我们进行网络编程时通常是在应用层编码,应用层下面就是传输层。应用层往下传输数据时不必担心也没有必要知道数据的传输情况如何,这个具体地交给传输层来解决,所以我们有必要简单了解一下传输层的两个重要协议 TCP 和 UDP。
TCP 协议
TCP 全称 Transmission Control Protocol,即传输控制协议,它有如下特点:
UDP 协议
UDP 全称 User Datagram Protocol,即用户数据报协议,它有如下特点:
在我们的认知里一定是安全、稳定的才好,那传输层为什么还要引入一个不可靠传输方式的 UDP 协议呢?TCP 协议虽然是可靠传输,但是“可靠”是要付出一些效率上的代价的,可能会导致传输速度比较慢,而且实现起来相对复杂;以这个角度去看 UDP 协议,虽然可能在传输过程中出现丢包的情况,但效率上是要比 TCP 更快的。通常两个协议我们可以搭配起来使用,网速快时用 TCP 协议,网速慢时用 UDP 协议,但如果是要传输重要数据的话就应该用 TCP 了。
我们知道,内存中的数据权值排列相对于内存地址的大小有大端和小端之分:
数据在发送时,发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序以字节为单位发出;接收主机把接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序以字节为单位保存的。即先发出低地址的数据,后发出高地址的数据;接收到的数据也是按低地址到高地址的顺序接收。
如果发送端和接收端主机的存储字节序不同,则会造成发送的数据和识别出来的数据不一致的问题,如下图所示:
网络在传输数据时同样有大端小端之分,TCP/IP 协议规定,网络数据流应采用大端字节序,即低地址高权值,不管这台主机是大端机还是小端机,,最后都要按照 TCP/IP 规定的网络字节序(大端)来发送/接收数据:
socket 通常也称为“套接字”,程序可以通过“套接字”向网络发出请求或者响应网络请求。socket 位于传输层之上、应用层之下。socket 编程是通过一系列系统调用完成应用层协议,如 FTP、Telent、HTTP 等应用层协议都是通过 socket 编程来实现的。
从套接字所处的位置来讲,套接字上连应用进程,下接网络协议栈,是应用程序与网络协议栈进行交互的接口。
套接字是对网络中应用进程之间进行双向通信的抽象,他提供了应用层进程利用网络协议栈交换数据的机制。
套接字的本质
Linux 和 UNIX 的 I/O 内涵是系统中的一切都是文件。当程序在执行任何形式的 I/O 时,程序都是在读或者在写一个文件描述符,从而实现操作文件,但是,这个文件可能是一个 socket 网络连接、目录、FIFO、管道、终端、外设、磁盘上的文件。一样的道理,socket 也是使用标准 Linux 文件描述符和其他网络进程进行通信的。
socket 函数基本为系统调用函数,它是操作系统向网络通信进程提供的函数接口。
从实现的角度来讲,套接字系列函数是一个复杂的软件模块,它包含了一定的数据结构和许多选项,由操作系统内核来管理。
Linux 系统是通过套接字(socket)函数来进行网络编程的。socket 技术提供了在 TCP/IP 模型各个层上的编程支持,该技术是先在内核中处理收到的各层协议数据,然后应用程序再以文件操作的方式接收内核返回的数据。
其中应用程序对文件的处理是通过一个文件描述符来进行的,socket 文件描述符可以看成普通的文件描述符来进行操作,这就是 Linux 系统设备无关性的好处,可以通过对文件描述符的读写操作来实现网络间数据流的传输。
重新理解 IP 地址与端口
端口是指网络中面向连接服务和无连接服务的通信协议端口,它是一种抽象的软件结构,包括一些数据结构和 I/O(基本输入/输出)缓冲区。
IP 用来标识公网中唯一的一台主机,而端口号则用来标识一台主机下不同的进程,IP 地址与端口号合起来标识的就是公网中唯一的一个进程。
在TCP/IP协议中, 用 “源IP”, “源端口号”, “目的IP”, “目的端口号”, “协议号” 这样一个五元组来标识一个网络通信,我们可以用 netstat -n 命令查看当前主机下已经建立链接的网络通信
IP地址、端口号、socket 套接字三者在数据结构上的联系
在套接字编程中,有三种常见的结构类型,它们用来存放socket地址信息。这三种结构类型分别为struct in_addr
、struct sockaddr
、struct sockaddr_in
,对这三种结构类型说明如下,使用它们需要包含头文件 #include <netinet/in.h>。
struct in_addr
专门用来存储 IP 地址,对于 IPv4 来说,IP 地址为 32 位无符号整数,其定义可以在 /usr/include/linux/in.h 下找到,具体 IP 地址的值存储在该结构的成员变量 s_addr 中:struct sockaddr
结构用来保存保存套接字的完整地址信息,其定义如下:struct sockaddr {
unsigned short sa_family; /* 地址簇,AF_xxx */
char sa_data[14]; /* 14字节的协议地址 */
};
struct sockaddr 结构中 sa_family 成员说明的是地址簇类型,一般为 “AF_INET”;而 sa_data 则包含主机的 IP 地址和端口号等信息。
struct sockaddr 结构类型经常被配合使用在 socket 相关的系统调用函数中,但这个结构中的 sa_data 字段包含了较多的信息,不便于实际编程和对其进行赋值,因此,又引入了 struct sockaddr_in 结构,该结构与 struct sockaddr 结构的大小相等,但能更好地处理 struct sockaddr 结构中的数据。
struct sockaddr_in
结构定义如下:通常我们都是对 struct sockaddr_in 结构进行初始化,之后我们使用 socket 相关的系统调用函数时,再将之前初始化好的 struct sockaddr_in 结构强制转化为 struct sockaddr 结构类型即可,然后传入 socket 相关的系统调用函数中即可。
三种地址结构的调用关系
为保证“大端”和“小端”字节序机器之间能相互进行正常的网络通信,需在发多字节数据时,将主机字节序转换成网络字节序;或者接受数据时,将网络字节序转换为主机字节序。
下面是一些整型数据(IP、端口号等)的字节序转换函数
所需头文件 | #include <arpa/inet.h> |
---|---|
函数说明 | 完成网络字节序与主机字节序的转换,注意已经完成转换了的数据就不要在重复转换了 |
函数原型 | uint16_t htons(uint16_t hostshort) //短整型主机转换为网络字节序 uint32_t htonl(uint32_t hostlong) //长整型主机转换为网络字节序 uint16_t ntohs(uint16_t netshort) //短整型网络转换为主机字节序 uint32_t ntohl(uint32_t netlong) //长整型网络转换为主机字节序 |
函数传入值 | hostshort、hostlong:为转换前的主机字节序数值 netshort、netlong为转换前的网络字节序数值 |
函数返回值 | ① htons、htonl返回转换后的网络字节序数值 ② ntohs、ntohl返回转换后的主机字节序数值 |
附加说明 | h表示主机,n表示网络,s表示短整数,l表示长整数,to表示转换 |
IP 地址转换函数是指完成点分十进制数 IP 地址(是一个字符串)与二进制数IP地址之间的相互转换。IP 地址转换主要由 inet_aton、inet_addr 和 inet_ntoa 这三个函数完成,但它们都只能处理 IPv4 地址,而不能处理 IPv6 地址。这三个函数的函数原型及其具体说明如下。
1、inet_addr
函数原型 | in_addr_t inet_addr(const char* cp) |
---|---|
函数说明 | 将点分十进制数 IP 地址转换为二进制数 IP 地址并完成网络字节序的转换 |
所需头文件 | #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> |
函数传入值 | cp:点分十进制数 IP 地址,如“10.10.10.1” |
函数返回值 | in_addr_t 一般为 32 位的unsigned int 成功:返回二进制数形式的IP地址 失败:返回一个常值 INADDR_NONE(32位均为1) |
2、inet_aton
函数原型 | int inet_aton(const char* cp, struct in_addr* inp) |
---|---|
函数说明 | 将点分十进制数 IP 地址转换为二进制数地址并完成网络字节序的转换 |
所需头文件 | #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> |
函数传入值 | cp:点分十进制数 IP 地址,如“10.10.10.1” inp:转换后的二进制数地址信息保存在 inp 这个 struct in_addr 结构中 |
函数返回值 | 成功:非0 失败:0 |
3、inet_ntoa
函数原型 | char* inet_ntoa(struct in_addr in) |
---|---|
函数说明 | 将二进制数 IP 地址转换为点分十进制数 IP 地址 |
所需头文件 | #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> |
函数传入值 | in:二进制数 IP 地址,注意类型是 struct in_addr,使用时该数据一般从套接字地址结构中拿到 |
函数返回值 | 成功:返回字符串指针,此指针指向了转换后的点分十进制数 IP 地址 失败:NULL |
在实际的编程中,对它们三个的使用方法如下:
struct sockaddr_in myad;
memset(&myad, 0, sizeof(struct sockaddr_in));
myad.sin_family = AF_INET;
myad.sin_port = htons(8080);
myad.sin_addr.s_addr = htonl(INADDR_ANY);
bind(serverFd, (struct sockaddr*)&myad, sizeof(myad));
PS:基本套接字函数的头文件都为:<sys/socket.h>、<sys/types.h> 这两个。
创建套接字要用到socket(...)
这个函数,该函数的原型如下:
头文件: <sys/socket.h>、<sys/types.h>
函数说明:创建一个 socket 文件描述符。
返回值:
参数说明:
domain
:即协议簇。
type
:即服务类型。
protocol
:即协议类型。
补充:下表列出了当进行 socket 调用时,其中的协议簇(domain)与服务类型(type)可能产生的组合。
- | AF_INET | AF_INET6 | AF_LOCAL | AF_ROUTE | AF_KEY |
---|---|---|---|---|---|
SOCK_STREAM | TCP | TCP | Yes | ||
SOCK_DGRAM | UDP | UDP | Yes | ||
SOCK_RAW | IPv4 | IPv6 | Yes | Yes |
头文件:<sys/socket.h>、<sys/types.h>
函数说明:将套接字地址(struct sockaddr)与 socket 文件描述符联系起来。利用 bind 绑定地址时,可以指定主机的IP地址和端口号。
此函数一般在客户端调用,我们在填充 struct sockaddr 结构中的 IP 地址时,可以传通配地址 INADDR_ANY(为宏定义,其值等于0),此时的含义是让服务器端计算机上所有网卡的 IP 地址都可以作为服务器的 IP 地址,也即监听外部客户端程序发送到服务器端所有网卡的网络请求。
参数说明:
函数返回值:
头文件:<sys/socket.h>、<sys/types.h>
函数说明:设置监听套接字
参数说明:
返回值:成功返回 0,失败返回 -1,失败原因存于 error 中
补充说明
对于被设置为监听套接字的 socket 文件描述符 sockfd,内核要维护两个队列:未完成连接队列和已完成连接队列,这两个队列之和不能超过backlog。
头文件:<sys/socket.h>、<types.h>
函数说明:接收 socket 连接,返回一个新的 socket 文件描述符,原 socket 文件描述符仍为 listen 函数所用,而新的 socket 文件描述符则用来处理连接的读写操作。
参数说明:
函数返回值:
附加说明
头文件:<sys/socket.h>、<sys/types.h>
函数说明:主动建立 socket 连接
函数传入值:
函数返回值:成功返回 0,失败返回 -1,失败原因存于 error 中。
UDP 套接字是无连续协议,必须使用 sendto 函数发送数据,使用 recvfrom 函数接收数据,且发送时需要指明目的地地址。sendto 函数与send 的功能基本相同,recvfrom 与recv的功能基本相同,只是 sendto 和 recvfrom 函数参数中都带有对端的地址信息,这两个函数是专门为 UDP 协议提供的。
头文件:<sys/socket.h>
函数说明:通过 socket 文件描述符发送数据到对端,用于 UDP 协议
参数说明:
socket
:socket 文件描述符message
:发送数据的首地址length
:发送数据的长度flags
:该参数可以设置为以下标志的组合
dest_addr
:存放目的主机的 IP 地址和端口信息,即socket 地址dest_len
:struct sockaddr 结构的大小,可设置为 sizeof(struct sockaddr)函数返回值:
头文件:<sys/socket.h>
函数说明:通过 socket 文件描述符从对方那儿接收数据,用于 UDP 协议
参数说明:
socket
:文件描述符。buffer
:接收数据的首地址(输出型参数)。length
:需要接受数据的长度。flags
:该参数可以设置为以下标志的组合。
address
:存放发送方的 IP 地址和端口(输出型参数)。address_len
:struct sockaddr 结构的大小,可设置为 sizeof(struct sockaddr)。函数返回值
UDP 协议是非连接非可靠的数据传输,常用在对数据质量要求不高的场合。UDP 服务器通常是非连接的,因而,UDP 服务器进程不需要像 TCP 服务器那样在监听套接字上接收新建的连接;UDP 只需要在绑定的端口上等待客户机发送过来的 UDP 数据报文,并对其进行处理和响应。
一个 TCP 服务进程只有在完成了对某客户机的服务后,才能为其它的客户机提供服务。而 UDP 服务器只是接收数据报文,处理并返回结果。UDP 支持广播和多播,如果要使用广播和多播,必须使用 UDP 套接字。UDP 套接字没有连接的建立和终止过程,UDP 只需要两个分组来交换一个请求和答应。UDP 不适合海量数据的传输。
① 建立 UDP 套接字
② 绑定套接字到特定的地址
③ 等待并接受客户端信息
④ 处理客户端请求
⑤ 发送信息给客户端
⑥ 关闭套接字
① 建立 UDP 套接字
② 发送信息给服务器
③ 接收来自服务器的信息
④ 关闭套接字
基本框架
服务端只有两个成员变量,端口号和自己的 socket 文件描述符,服务端不需要指定自己的 IP 地址,原因下文会做说明。
class UdpServer { public: // 构造函数,创建一个服务端对象时需要显示传入一个端口号给服务端 UdpServer(const int port) :_port(port) ,_sockfd(-1) {} // 析构函数,当服务端对象销毁时关闭打开的socket文件描述符 ~UdpServer() { if(_sockfd >= 0) { close(_sockfd); } } private: int _port; // 服务端进程的端口号 int _sockfd;// 服务端进程的socket打开文件描述符 };
初始化服务端
void InitServer() { // 1、创建socket文件描述符 if((_sockfd=socket(AF_INET, SOCK_DGRAM, 0)) == -1) { cerr<<"socket error"<<endl; return; } cout<<"socket sucess"<<endl; // 2、将服务端自己的套接字地址和刚刚创建的socket文件描述符绑定起来 struct sockaddr_in local; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(_port); local.sin_addr.s_addr = htonl(INADDR_ANY); if(bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) == -1) { cerr <<"bind error"<<endl; return; } cout<<"bind sucess"<<endl; }
补充说明
绑定操作我们是通过 bind(…) 函数来完成的,该函数是把套接字地址和 socket 文件描述符给绑定联系起来。而套接字地址包括:协议簇、端口号、IP 地址,在这里我们可以把 IP 地址设为 INADDR_ANY,表示该服务器可以收发本主机中所有网卡的数据。
最后还要注意通过 htons、htonl 等整型数据字节序转化函数把 IP 地址和端口号这些整型数据的主机字节序转化成网络字节序,然后再放入套接字地址的成员变量中。
启动服务端
void Loop() { #define SIZE 128 // buffer用于接收客户端传来的数据 char buffer[SIZE]; // peer用于接收客户端的套接字地址信息 struct sockaddr_in peer; // len用来接收客户端套接字地址结构的大小 socklen_t len = sizeof(peer); while(true) { // 1、通过recvfrom函数不断接收客户端传来的数据和信息 ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len); // 2、解析客户端的数据和信息 if(size >= 0) { buffer[size] = '\0'; int port = ntohs(peer.sin_port); string ip = inet_ntoa(peer.sin_addr); cout<<'['<<ip<<' '<<port<<"]#"<<buffer<<endl; } else { cerr<<"recvfrom error"<<endl; } } }
注意事项
recvfrom(…) 函数不仅可以拿到客户端传来的数据,还可以拿到客户端的 IP 地址和端口号,服务端有了客户端的 IP 地址和端口号之后我们可以通过 sendto(…) 函数再发送数据回去给客户端进程,实现客户端、服务端的双向网络通信。
基本框架
客户端一般是要把任务发送给服务器,然后让服务器去处理任务,所以客户端进程需要知道服务器的 IP 地址和端口号,才能把任务发送过去。
class UdpClient { public: // 构造函数,需要显示传入目的服务端的IP地址和端口号,作为数据发送的目的地 UdpClient(const string& serverIp, const int serverPort) :_sockfd(-1) ,_serverIp(serverIp) ,_serverPort(serverPort) {} // 析构函数,关闭打开的socket文件描述符 ~UdpClient() { if(_sockfd >= 0) { close(_sockfd); } } private: int _sockfd; // 客户端进程的socket文件描述符 string _serverIp;// 服务端进程的IP地址 int _serverPort; // 服务端进程的端口号 };
初始化客户端
这里只需要创建客户端网络进程的 socket 文件描述符即可,而不需要将 socket 文件描述符和自己的套接字地址进行绑定。因为没人会关心你客户端的 IP 地址和端口号,而服务端作为服务的提供者,所有客户端进程都需要向服务端进程发送任务,即服务端的 IP 地址和端口号是要被众所周知的,所以服务端需要进行绑定操作而客户端不需要。
void InitUdpClient()
{
if((_sockfd=socket(AF_INET, SOCK_DGRAM, 0)) == -1)
{
cerr<<"socket error"<<endl;
return;
}
}
启动客户端
void Start() { string msg; // 在struct sockaddr_in结构中填入服务端的套接字地址 struct sockaddr_in peer; memset(&peer, 0, sizeof(peer)); peer.sin_family = AF_INET; peer.sin_port = htons(_serverPort); peer.sin_addr.s_addr = inet_addr(_serverIp.c_str()); // 通过sendto函数发送数据到服务端 while(true) { cout<<"Please Enter# "; getline(cin, msg); sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer)); } }
把上面 UDP 的服务端和客户端的实现代码分别写到udp_server.h 和 udp_client.h 两个头文件中。
udp_server.cpp
#include "udp_server.h" // 运行可执行程序时,这里通过命令行参数传入服务端的端口号 int main(int argc, char** argv) { if(argc != 2) { cerr<<"Usage:"<<"./ServerName"<<" port"<<endl; return -1; } // 1、把第二个命令行参数转为整型,拿到端口号,用这个端口号去构造一个服务端对象 UdpServer* svr = new UdpServer(atoi(argv[1])); // 2、初始化服务端对象 svr->InitServer(); // 3、启动服务端对象 svr->Loop(); return 0; }
udp_client.cpp
#include "udp_client.h" // 运行可执行程序时,通过命令行参数传入目的服务端的IP地址和端口号 int main(int argc, char** argv) { if(argc != 3) { cout<<"Usage:"<<"./ServerName"<<" ServerIp ServerPort"<<endl; return -1; } // 1、解析命令行参数传入的IP地址和端口号 string serverIp = argv[1]; int serverPort = atoi(argv[2]); // 2、构造一个客户端对象 UdpClient* clt = new UdpClient(serverIp, serverPort); // 3、初始化客户端 clt->InitUdpClient(); // 4、启动客户端 clt->Start(); return 0; }
结果测试
编译生成可执行程序
分别启动可执行程序,进行本地环回测试
发现客户端发送的数据能被服务器接收到
TCP 套接字编程经常使用在客户端/服务器编程模型(简称C/S模型)中,C/S模型根据复杂度,可分为简单的客户端/服务器模型和复杂的客户端/服务器模型。简单的客户端/服务器模型是一对一关系,即一个服务器器某一时间段内只对应处理一个客户端的请求,迭代服务器模型属于此模型。复杂的客户端/服务端模型是一对多关系,即一个服务器端某一时间段内对应处理多个客户端的请求,并发服务器模型属于此模型。迭代服务器模型和并发服务器模型是 socket 编程中最常使用的两种编程模型。
TCP 的迭代服务器模型和并发服务器模型的服务端处理流程如下图所示:
下图是更加具体的 TCP 套接字编程模型图,此模型不仅适合迭代服务器,也适合并发服务器,两者实现的流程类似,只不过并发服务器接收客户请求(accept)后会调用 fork() 生成子进程,然后把客户端的请求交给子进程处理。
①:创建套接字
②:绑定套接字
③:设置套接字为监听模式,进入被动接收连接状态
④:接受请求,建立连接
⑤:读写数据
⑥:终止连接
①:创建套接字
②:与远程服务器建立连接
③:读写数据
④:终止连接
在网络程序中,向套接字文件描述符写数据时有以下两种可能:
与向套接字文件描述符写数据不同,读数据有三种可能:
下面代码实现的是典型的迭代服务器,服务端的功能是接收客户发送来的字符串数据并原封不动地发回去。
tcp_server.h:用来存放服务端类的实现。
#define BACK_LOG 10 #define BUFF_SIZE 1024 class TcpServer { public: TcpServer(const int port) :_port(port) ,_listenSock(-1) {} ~TcpServer() { if(_listenSock >= 0) { close(_listenSock); } } void InitServer() { // 1、创建套接字 _listenSock = socket(AF_INET, SOCK_STREAM, 0); // 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; bind(_listenSock, (struct sockaddr*)&local, sizeof(local)); // 3、设置监听套接字 listen(_listenSock, BACK_LOG); } // 服务客户端 void Service(const int linkSock, const string& ip, const int port) { char buff[BUFF_SIZE]; while(1) { ssize_t size = read(linkSock, buff, sizeof(buff)-1); if(size > 0) { buff[size] = 0; cout<<'['<<ip<<':'<<port<<"]# "<<buff<<endl; write(linkSock, buff, size); } else if(size == 0) { cout<<"client close!"<<endl; break; } else { cerr<<"read error"<<endl; break; } } // 服务完成后要记得关闭该连接套接字 close(linkSock); } // 启动服务器 void LoopServer() { struct sockaddr_in peer; memset(&peer, 0, sizeof(peer)); socklen_t len = sizeof(peer); // 不断地监听获取客户端的连接请求 while(1) { int linkSock = accept(_listenSock, (struct sockaddr*)&peer, &len); // 若该套接字监听失败,继续监听下一个套接字即可 if(linkSock == -1) { cout<<"accept error, continue next link"<<endl; continue; } int port = ntohs(peer.sin_port); string ip = inet_ntoa(peer.sin_addr); cout<<"get a new link, sockfd is "<<linkSock<<endl; // 连接成功后,为客户端提供服务 Service(linkSock, ip, port); } } private: int _port; int _listenSock; };
tcp_server.cpp:创建一个服务端对象,并初始化和启动它。
#include "tcp_server.h" int main(int argc, char* argv[]) { if(argc != 2) { cout<<"Usage:./ServerProc Serverport"<<endl; exit(-1); } // 解析参数 int port = atoi(argv[1]); // 根据参数去创建一个服务端对象 TcpServer* svr = new TcpServer(port); // 初始化、启动务端 svr->InitServer(); svr->LoopServer(); // 最后delete服务端对象 delete svr; return 0; }
tcp_client.h:用来存放客户端类的实现
class TcpClient { public: TcpClient(const string& serverIp, const int serverPort) :_serverIp(serverIp) ,_serverPort(serverPort) ,_linkSock(-1) {} ~TcpClient() { if(_linkSock >= 0) { close(_linkSock); } } // 初始化客户端 void InitClient() { // 初始化阶段只需创建套接字即可 _linkSock = socket(AF_INET, SOCK_STREAM, 0); } // 连接成功后,发送信息给服务端,然后在接收服务端返回的信息 void Request() { string msg; char echoBuff[1024]; while(1) { cout<<"please enter# "; getline(cin, msg); write(_linkSock, msg.c_str(), msg.size()); ssize_t size = read(_linkSock, echoBuff, sizeof(echoBuff)-1); if(size > 0) { echoBuff[size] = 0; cout<<"server echo# "<<echoBuff<<endl;; } else if(size == 0) { cout<<"server close!"<<endl; break; } else { cerr<<"read error"<<endl; break; } } } // 启动客户端,用已经创建出来的套接字去连接服务端并请求其处理任务 void Start() { struct sockaddr_in peer; memset(&peer, 0, sizeof(peer)); peer.sin_family = AF_INET; peer.sin_port = htons(_serverPort); peer.sin_addr.s_addr = inet_addr(_serverIp.c_str()); if(connect(_linkSock, (struct sockaddr*)&peer, sizeof(peer)) == -1) { cerr<<"connect error"<<endl; } else { cout<<"connect success"<<endl; Request(); } } private: string _serverIp; int _serverPort; int _linkSock; };
tcp_client.cpp:创建一个客户端对象并初始化和启动它。
#include "tcp_client.h" int main(int argc, char* argv[]) { if(argc != 3) { cout<<"Usage:./clientProc serverIp serverPort"<<endl; exit(-1); } // 解析参数 string ip = argv[1]; int port = atoi(argv[2]); // 根据参数去创建一个客户端对象 TcpClient* clt = new TcpClient(ip, port); // 初始化、启动客户端 clt->InitClient(); clt->Start(); // 最后delete客户端对象 delete clt; return 0; }
两个会话分别启动服务端(左边)和客户端(右边),客户端发送数据给服务端,结果服务端能接收到数据并回响给客户端,说明该迭代服务器实现成功。
迭代服务器存在明显的的缺点,即一个服务器端某一时间段内只对应处理一个客户端的请求,下图可以看到如果再另起一个客户端进程去连接服务器,因为上一个客户端的服务还没有完成所以新起的客户端进程并不能享受服务。
进程是一个程序的一次运行过程,它是一个动态实体,是独立的任务,它拥有独立的地址空间、执行堆栈、文件描述符等。每个进程拥有独立的地址空间,在进程不存在父子关系的情况下,互不影响。
进程的终止存在两种可能:父进程先于子进程终止(由init进程领养),子进程先于主进程终止。对于后者,系统内核为子进程保留一定的状态信息(进程ID、终止状态、CPU时间等),并向其父进程发送SIGCHLD信号。当父进程调用wait或waitpid函数时,将获取这些信息,获取后内核将对僵尸进程进行清理。如果父进程设置了忽略SIGCHLD信号或对SIGCHLD信号提供了处理函数,即使不调用wait或waitpid函数,内核也会清理僵尸进程。
父进程调用wait函数处理子进程退出信息时,会存在下面所述的问题。在有多个子进程的情况下,wait函数只等待最先到达的子进程的终止信息。比如下图中父进程有三个子进程,由于SIGCHLD信号不排队,在SIGCHLD信号同时到来后,父进程的wait函数只执行一次,这样将留下两个“僵尸进程”,使用waitpid函数并设置WNOHANG选项可以解决这个问题。
综上所述,在多进程并发的情况下,防止子进程变成僵尸进程的常见方法有如下三种。
①:父进程调用signal(SIGCHLD,SIG_IGN)对子进程退出信号进行忽略,或者把SIG_IGN替换为其他处理函数,设置对SIGCHLD信号的处理。
②:父进程调用waitpid(-1, NULL, WNOHANG)对所有的子进程SIGCHLD信号进行处理。
③:服务端进程先创建一个子进程(儿子进程),然后这个子进程再创建一个子进程(孙子进程),让孙子进程去处理任务并终止儿子进程,这样孙子进程处理完任务后因为没有父进程了,所以这个孙子进程会被init进程领养并释放。
下图画出了并发服务器文件描述符的变化流程图。其中listenfd为服务端的socket监听文件描述符,connfd为accept函数返回的socket连接文件描述符。
服务器调用accept函数时,客户端与服务端文件描述符如图所示:
服务器调用accept函数后,客户端与服务端文件描述符如图所示:
服务端调用fork函数后,客户端与服务端文件描述符如下图所示:
服务端父进程关闭连接套接字,子进程关闭监听套接字,客户端与服务端文件描述符状况如下图所示:
PS:并发服务器fork后父进程一定要关闭子进程的连接套接字;而子进程要关闭父进程的监听套接字,以免误操作。
并发服务器处理流程
① 客户端首先发起链接。
② 服务端进程accept打开一个新的连接套接字与客户端进行连接,accept在一个while(1)循环内等待客户端的连接。
③ 服务端fork一个子进程,同时父进程close关闭子进程连接套接字,循环等待下一进程。
④ 服务端子进程colse父进程监听套接字,并用连接套接字保持与客户端的连接,客户端发送数据到服务端进程,然后阻塞等待服务端返回。
⑤ 子进程接收数据,进行业务处理,然后发送数据给客户端。
⑥ 子进程关闭连接,然后退出。
并发服务器服务端代码
只需在迭代服务器的基础上修改服务端的启动部分代码即可,当服务端连接成功拿到新的连接套接字时,服务端进程fork创建子进程,让子进程去执行客户端发来的任务,注意服务端进程需要忽略对SIGCHLD信号的处理。
先启动一个客户端1和一个服务端,发现确实能够正常通信:
再启动一个客户端2,也能够正常和服务端通信:
补充1:另一多进程版本的服务端编写
服务端进程先创建一个子进程(儿子进程),然后这个子进程再创建一个子进程(孙子进程),让孙子进程去处理任务并终止儿子进程,这样孙子进程处理完任务后因为没有父进程了,所以这个孙子进程会被init进程领养并释放。
只需修改服务端连接成功之后的那部分的代码即可:
结果演示,一个服务端进程依然可以同时为多个客户端进程提供服务:
补充2:多线程版本服务端编写
创建进程的开销是要比创建线程大得多的,我们的主线程在连接成功后可以考虑去创建线程来处理任务,在编码时要注意以下几点:
涉及到的客户端类代码如下:
class TcpServer { public: //...其他成员函数省略 // 多线程版本 // 启动服务器 void LoopServer() { struct sockaddr_in peer; memset(&peer, 0, sizeof(peer)); socklen_t len = sizeof(peer); // 不断地监听获取客户端的连接请求 while(1) { int linkSock = accept(_listenSock, (struct sockaddr*)&peer, &len); // 若该套接字监听失败,继续监听下一个套接字即可 if(linkSock == -1) { cout<<"accept error, continue next link"<<endl; continue; } int port = ntohs(peer.sin_port); string ip = inet_ntoa(peer.sin_addr); cout<<"get a new link, sockfd is "<<linkSock<<endl; // 建立连接后,创建子线程去处理任务 Param* pm = new Param(linkSock, ip, port); pthread_t tid; pthread_create(&tid, nullptr, Routine, pm); } } // 子线程处理任务函数(注意要设为静态的,要不然会参数里会有this指针) static void* Routine(void* arg) { pthread_detach(pthread_self()); Param* pm = (Param*)arg; Service(pm->_sockfd, pm->_ip, pm->_port); delete pm; return nullptr; } // 服务客户端(也要设为静态的,因为Routine的逻辑中有使用到该函数) static void Service(const int linkSock, const string& ip, const int port) { char buff[BUFF_SIZE]; while(1) { ssize_t size = read(linkSock, buff, sizeof(buff)-1); if(size > 0) { buff[size] = 0; cout<<'['<<ip<<':'<<port<<"]# "<<buff<<endl; write(linkSock, buff, size); } else if(size == 0) { cout<<"client close!"<<endl; break; } else { cerr<<"read error"<<endl; break; } } // 服务完成后要记得关闭该连接套接字 close(linkSock); } private: int _port; int _listenSock; };
结果演示:不论主线程还是其创造出来的子线程,它们都共属同一个进程,每一个子线程要使用一个自己的连接套接字去处理任务,又因为它们共用同一张打开文件描述符表,所以各自分配到的套接字不同。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。