因特网是在网络级进行互联的,因此,因特网在网络层(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协议,网速慢时用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地址与端口号合起来标识的就是网络中唯一的进程。
如果把IP地址比作一栋旅馆,端口就是旅馆里的一个个房间。一个IP地址的端口可以有65536(即2^16)个之多。端口是通过端口号来标识的,端口号是个16byte位的整数,范围是0 ~ 65535( 2^16-1)。其中端口1~1024是系统保留端口。
一次socket通信连接会涉及源IP地址、源端口、目的IP地址和目的端口四个要素。源IP地址、源端口标识的是客户端进程,其中源端口是操作系统随机分配的;目的IP地址、目的端口标识的是服务端进程,其中目的端口是由服务器程序指定的。
IP地址、端口号、socket套接字三者在数据结构上的联系
在套接字编程中,有三种常见的结构类型,它们用来存放socket地址信息。这三种结构类型分别为struct in_addr
、struct sockaddr
、struct sockaddr_in
,对这三种结构类型说明如下,使用它们需要包含头文件#include
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]; /* 14B的协议地址 */
};
struct sockaddr结构中sa_family成员说明的是地址簇类型,一般为“AF_INET”;而sa_data则包含主机的IP地址和端口等信息。
struct sockaddr结构类型使用在socket相关的系统调用函数中,但这个结构中的sa_data字段可以包含较多的信息,不便于实际编程和对其进行赋值,因此,又建立了struct sockaddr_in结构,该结构与struct sockaddr结构的大小相等,能更好地处理struct sockaddr结构中的数据。对struct sockaddr_in结构变量进行赋值完成后进行socket相关的系统调用函数时,再将struct sockaddr_in结构变量强制转化为struct sockaddr结构类型即可。
在实际应用的编程中,对套接字地址结构的使用方法和流程如下:
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));
为保证“大端”和“小端”字节序机器之间能相互进行正常的网络通信,需在发送多字节的整数时,将主机字节序转换成网络字节序,或将网络字节序转换为主机字节序。字节序转换主要是针对整型数据进行的,字符型由于是单字节,所以不存在这个问题。整型整型字节序转换函数原型及其说明如下。
所需头文件 | #include |
---|---|
函数说明 | 完成网络字节序与主机字节序的转换,注意已经完成转换了的整数就不要在重复转换了 |
函数原型 | 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 #include #include |
函数传入值 | 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 #include #include |
函数传入值 | cp:点分十进制数IP地址,如“10.10.10.1” inp:转换后的二进制数地址信息保存在inp中 |
函数返回值 | 成功:非0 失败:0 |
3、inet_ntoa
函数原型 | char* inet_ntoa(struct in_addr in) |
---|---|
函数说明 | 将二进制数地址转换为点分十进制数IP地址 |
所需头文件 | #include #include #include |
函数传入值 | in:二进制数IP地址,注意类型是struct in_addr,该数据一般从套接字地址结构中拿到 |
函数返回值 | 成功:返回字符串指针,此指针指向了转换后的点分十进制数IP地址 失败:NULL |
PS:基本套接字函数的头文件都为:#include
(1)socket函数
创建套接字要用到socket
这个函数,该函数的原型如下:
头文件:#include
函数返回值:
参数说明:
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 |
头文件:#include
函数说明:将一个套接字地址与socket文件描述符联系起来。利用bind绑定地址时,可以指定主机的IP地址和端口号。
此函数一般为客户端调用,我们在填充IP地址时可以使用通配地址INADDR_ANY(为宏定义,其值等于0),此时的含义是让服务器端计算机上的所有网卡的IP地址都可以作为服务器的IP地址,也即监听外部客户端程序发送到服务器端所有网卡的网络请求。
参数说明:
函数返回值:
头文件:#include
函数说明:设置监听套接字
参数说明:
函数返回值:成功返回0,失败返回-1,失败原因存于error中。
附加说明
对于监听套接字文件描述符sockfd,内核要维护两个队列,分别为未完成连接队列和已完成连接队列,这两个队列之和不能超过backlog。
头文件:#include
函数说明:接受socket连接,返回一个新的socket文件描述符,原socket文件描述符仍为listen函数所用,而新的socket文件描述符用来处理连接的读写操作。
参数说明:
函数返回值:
附加说明
头文件:#include
函数说明:主动建立socket连接
函数传入值:
函数返回值:成功返回0,失败返回-1,失败原因存于error中。
UDP套接字是无连续协议,必须使用sendto函数发送数据,使用recvfrom函数接收数据,且发送时需要指明目的地地址。sendto函数与send的功能基本相同,recvfrom与recv的功能基本相同,只是sendto和recvfrom函数参数中都带有对端的地址信息,这两个函数是专门为UDP协议提供的。
头文件:#include
函数说明:通过socket文件描述符发送数据到对端,用于UDP协议
参数说明:
socket
:socket文件描述符message
:发送数据的首地址length
:发送数据的长度flags
:该参数可以设置为以下标志的组合
dest_addr
:存放目的主机的IP地址和端口信息,即socket地址dest_len
:to的长度,可设置为sizeof(struct sockaddr)函数返回值:
头文件:#include
函数说明:通过socket文件描述符从对方接收数据,用于UDP协议
参数说明:
socket
:文件描述符。buffer
:接收数据的首地址(输出型参数)。length
:需要接受数据的长度。flags
:该参数可以设置为以下标志的组合。
address
:存放发送方的IP地址和端口(输出型参数)。address_len
:socket地址的长度,可设置为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等整型数据字节序转化函数把P地址和端口号这些整型数据的字节序转化成网络字节序,然后再放到套接字地址变量中。
启动服务端
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套接字编程模型图,此模型不仅适合迭代服务器,也适合并发服务器,两者实现的流程类似,只不过并发服务器接收客户请求(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:另一多进程版本的服务端编写
服务端进程先创建一个子进程(儿子进程),然后这个子进程再创建一个子进程(孙子进程),让孙子进程去处理任务并终止儿子进程,这样孙子进程处理完任务后因为没有父进程了,所以这个孙子进程会被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;
};
结果演示:不论主线程还是其创造出来的子线程,它们都共属同一个进程,每一个子线程要使用一个自己的连接套接字去处理任务,又因为它们共用同一张打开文件描述符表,所以各自分配到的套接字不同。