1.基础预备知识
1.1源ip和目的ip
在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址
源IP地址表示发起通信的设备的IP地址。它是数据包的出发点,标识了数据包的来源。当一个设备发送数据包到网络上的其他设备时,该数据包的源IP字段会被设置为该设备的IP地址。
目的IP地址表示接收通信的设备的IP地址。它是数据包的目标,标识了数据包的目的地。当一个设备接收到来自网络上的数据包时,该数据包的目的IP字段用于确定数据包应该传递给哪个设备。
1.2端口号
端口号(port)是传输层协议的内容.
-
端口号是一个2字节16位的整数;
-
端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
-
IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
-
一个端口号只能被一个进程占用, 一个进程可以绑定多个端口号
源端口号表示发起通信的应用程序或服务的端口号。当一个应用程序或服务发送数据包到网络上的其他设备时,该数据包的源端口字段会被设置为该应用程序或服务的端口号。
目的端口号表示接收通信的应用程序或服务的端口号。当一个设备接收到来自网络上的数据包时,该数据包的目的端口字段用于确定数据包应该传递给哪个应用程序或服务。
1.3总结
实质上IP是网络号+主机号,网络号定位你所在的网络,主机号定位该网络中你的主机,而端口标识主机里唯一的
进程,所以端口号+ip地址可以标识网络上的某一台主机的某一个唯一进程,那么IP地址+端口号组合就被称之为网络套接字地址
而我们的套接字(Socket)是计算机网络中一套用于实现网络通信的编程接口(API)
2.简单认识tcp协议和udp协议
TCP(传输控制协议)和UDP(用户数据报协议)是网络通信中常用的两种传输协议。
(其中细节先不过多介绍先给大家建立一些标签化的大概认知 )
TCP:
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
UDP:
传输层协议
无连接
不可靠传输
面向数据报
3.网络字节序
内存中的多字节数据相对于内存地址有大端和小端之分,因此网络数据流为了全平台能够通用TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;
为使网络程序具有可植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;
如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
4.socket编程常见的api(这里把基本的列出来,后面会在代码中单独拎出来讲解)
创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,socklen_t address_len);
开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,socklen_t* address_len);
建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
5.sockaddr结构
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX Domain
Socket. 然而, 各种网络协议的地址格式并不相同
在跨网络通信的时候我们需要传入IP和端口号 而本地通信则不需要
因此套接字提供了sockaddr_in
结构体和sockaddr_un
结构体
为了让套接字的网络通信和本地通信能够使用同一套函数接口 于是就出现了sockeaddr
结构体 该结构体与sockaddr_in和sockaddr_un的结构都不相同 但这三个结构体头部的16个比特位都是一样的 这个字段叫做协议家族,在设置参数时就可以通过设置协议家族这个字段 来表明我们是要进行网络通信还是本地通信。
sockaddr 结构
sockaddr_in 结构
in_addr结构
in_addr用来表示一个IPv4的IP地址. 其实就是一个32位的整数
6.一个简单的udp程序教程
(友情提示:新手再看这一段前老老实实先把之前的前置知识看完)
6.1socket函数
int socket(int domain, int type, int protocol);
参数说明:
1.domain:指定套接字的地址族(address family),常见的值有AF_INET(IPv4)和AF_INET6(IPv6)。
2.type:指定套接字的类型,常见的值有SOCK_STREAM(流套接字,用于TCP)和SOCK_DGRAM(数据报套接字,用于UDP)。
3.protocol:指定套接字使用的协议,一般可以设置为0,表示根据domain和type自动选择合适的协议。
返回值:
1.如果函数调用成功,返回一个新的套接字描述符(socket descriptor),它是一个非负整数。
2.如果函数调用失败,返回-1,并设置全局变量errno来指示错误类型。
例子:
#include <sys/types.h>
#include <sys/socket.h>
int main() {
int sockfd;
// 创建一个IPv4 TCP套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket");
return 1;
}
// 使用套接字进行后续操作
// 关闭套接字
close(sockfd);
return 0;
}
socket函数究竟做了什么? |
---|
socket函数是被进程所调用的,而每一个进程在系统层面上都有一个进程地址空间PCB(task_struct)、文件描述符表(files_struct)以及对应打开的各种文件。而文件描述符表里面包含了一个数组fd_array,其中数组中的0、1、2下标依次对应的就是标准输入、标准输出以及标准错误。
当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。
其中每一个struct file结构体中包含对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区有关的关联内容等。其中文件对应的属性在内核当中是由struct inode结构体来维护的,而文件对应的操作方法实际就是一堆的函数指针(比如read和write)在内核当中就是由struct file_operations结构体来维护的。
对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作。而对于现在socket函数打开的“网络文件”来说,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,而网卡则是负责数据发送的,因此数据最终就发送到了网络当中。
6.2bind函数
例子中的使用到的函数的补充:
htons()是一个用于字节序转换的函数,其目的是将16位整数值(端口号)从主机字节序转换为网络字节序(大端字节序)。
uint16_t htons(uint16_t hostshort);
hostshort:一个16位整数,表示要进行字节序转换的值。//案例当中用了int这个类型会自动进行类型转换,不影响最终结果
将字符串IP转换成整数IP(并且是网络序列)的函数叫做inet_addr,该函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
cp:一个指向包含IPv4地址字符串的字符数组的指针。
inet_addr()函数返回一个in_addr_t类型的值,表示转换后的IPv4地址。
inet_ntoa()是一个用于将32位无符号整数(in_addr_t)表示的网络字节序的IPv4地址转换为点分十进制表示的字符串的函数。
char *inet_ntoa(struct in_addr in);
in:一个struct in_addr类型的结构体,表示网络字节序的IPv4地址。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
1.sockfd:套接字描述符,表示要进行绑定操作的套接字。
2.addr:指向struct sockaddr类型的指针,表示要绑定的本地地址。需要根据套接字类型(IPv4或IPv6)将其转换为正确的结构体类型(struct sockaddr_in或struct sockaddr_in6)。可以将IPv4地址转换为通用的struct sockaddr类型。
3.addrlen:表示addr结构体的长度,以字节为单位。
bind()函数的返回值为整型,表示函数执行的结果。如果绑定成功,则返回0;如果出现错误,则返回-1,并设置全局变量errno以指示具体的错误类型。
例子:
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; //直接用就行库里面写好了,用于表示IPv4的ip地址和端口号。
memset(&local, '\0', sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = inet_addr(_ip.c_str());
//struct sockaddr_in {
// sa_family_t sin_family; // 地址家族,一般为AF_INET
// in_port_t sin_port; // 端口号
// struct in_addr sin_addr; // IPv4地址
// char sin_zero[8]; // 填充字节,通常设置为0
// };
//绑定
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地址
};
现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。
而我们的绑定操作会将IP地址和端口号告诉对应的网络文件,此时就可以改变网络文件当中文件操作函数的指向,将对应的操作函数改为对应网卡的操作方法,此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来。
6.3recvfrom函数
recvfrom()函数是用于从已连接或未连接的套接字接收数据的函数。//主要用于UDP中,tcp基本不用
size_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
参数说明:
1.sockfd:套接字描述符,表示要接收数据的套接字。
2.buf:指向接收数据的缓冲区的指针。
3.len:接收缓冲区的大小。
4.flags:接收操作的标志,可以为0或一些特定的标志,如MSG_DONTWAIT、MSG_PEEK等。
5.src_addr:指向struct sockaddr类型的结构体的指针,用于存储发送方的地址信息。
6.addrlen:指向存储地址信息长度的变量的指针。
recvfrom()函数返回一个ssize_t类型的值,表示实际接收到的字节数。如果返回值为0,表示连接已关闭。如果返回值为-1,表示发生错误,可以通过查看errno来获取具体的错误信息。
使用案例:
class UdpServer
{
public:
void Start()
{
#define SIZE 128
char buffer[SIZE];
for (;;){
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); //网络字节序的16位数据转换为主机字节序
std::string ip = inet_ntoa(peer.sin_addr);
std::cout << ip << ":" << port << "# " << buffer << std::endl;
}
else{
std::cerr << "recvfrom error" << std::endl;
}
}
}
private:
int _sockfd; //文件描述符
int _port; //端口号
std::string _ip; //IP地址
};
tips:注意: 如果调用recvfrom函数读取数据失败,我们可以打印一条提示信息,但是不要让服务器退出,服务器不能因为读取某一个客户端的数据失败就退出。
6.4sendto函数(常用于udp中发送数据,tcp可以用但是基本不常见)
//sendto() 函数是用于发送数据的函数,主要用于 UDP(用户数据报协议)套接字。它允许你将数据发送到指定的目标地址。
#include <sys/types.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);
参数解释:
sockfd:套接字描述符,用于标识要发送数据的套接字。
buf:待写入数据的存放位置。
len:期望写入数据的字节数。
flags:可选的标志参数,用于控制发送操作的行为,通常设置为 0。
dest_addr:指向目标地址的结构体指针,指定数据发送的目标地址。
addrlen:目标地址结构体的长度。
例子:
class UdpClient
{
public:
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());
for (;;){
std::cout << "Please Enter# ";
getline(std::cin, msg);
sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
}
}
private:
int _sockfd; //文件描述符
int _server_port; //服务端端口号
std::string _server_ip; //服务端IP地址
};
鉴于构造客户端时需要对应服务端的IP地址和端口号,我们这里也可以引入命令行参数。当我们运行客户端时直接在后面跟上对应服务端的IP地址和端口号即可。
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;
}
6.5命令行参数的使用
鉴于构造服务器时需要传入IP地址和端口号,我们这里可以引入命令行参数。此时当我们运行服务器时在后面跟上对应的IP地址和端口号即可。
由于云服务器的原因,传入IP地址无效,目前我们就手动将IP地址设置为127.0.0.1。IP地址为127.0.0.1实际上等价于localhost表示本地主机,我们将它称之为本地环回(本质也是给个假的虚拟地址),相当于我们一会先在本地测试一下能否正常通信,然后再进行网络通信的测试。
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;
}
需要注意的是,**agrv数组里面存储的是字符串,而端口号是一个整数,因此需要使用atoi函数将字符串转换成整数。**然后我们就可以用这个IP地址和端口号来构造服务器了,服务器构造完成并初始化后就可以调用Start函数启动服务器了。
此时带上端口号运行程序就可以看到套接字创建成功、绑定成功,现在服务器就在等待客户端向它发送数据。
我们可以通过netstat命令来查看当前网络的状态,这里我们可以选择携带nlup选项
netstat常用选项说明:
- -n:直接使用IP地址,而不通过域名服务器。
- -l:显示监控中的服务器的Socket。
- -t:显示TCP传输协议的连线状况。
- -u:显示UDP传输协议的连线状况。
- -p:显示正在使用Socket的程序识别码和程序名称。
其中netstat命令显示的信息中,Proto表示协议的类型,Recv-Q表示网络接收队列,Send-Q表示网络发送队列,Local Address表示本地地址,Foreign Address表示外部地址,State表示当前的状态,PID表示该进程的进程ID,Program name表示该进程的程序名称。
其中Foreign Address写成0.0.0.0:*表示任意IP地址、任意的端口号的程序都可以访问当前进程。
6.6客户端绑定问题
首先,由于是网络通信,通信双方都需要找到对方,因此服务端和客户端都需要有各自的IP地址和端口号,只不过服务端需要进行端口号的绑定,而客户端不需要。
因为服务器就是为了给别人提供服务的,因此服务器必须要让别人知道自己的IP地址和端口号,IP地址一般对应的就是域名,而端口号一般没有显示指明过,因此服务端的端口号一定要是一个众所周知的端口号,并且选定后不能轻易改变,否则客户端是无法知道服务端的端口号的,这就是服务端要进行绑定的原因,只有绑定之后这个端口号才真正属于自己,因为一个端口只能被一个进程所绑定,服务器绑定一个端口就是为了独占这个端口。
而客户端在通信时虽然也需要端口号,但客户端一般是不进行绑定的,客户端访问服务端的时候,端口号只要是唯一的就行了,不需要和特定客户端进程强相关。
如果客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了。所以客户端的端口只要保证唯一性就行了,因此客户端端口可以动态的进行设置,并且客户端的端口号不需要我们来设置,当我们调用类似于sendto这样的接口时,操作系统会自动给当前客户端获取一个唯一的端口号。
也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动。
6.7INADDR_ANY
在通过本地测试后,接下来需要进行网络测试,那是不是直接让服务端绑定我的公网IP,此时这个服务端就能够被外网访问了呢?
理论上确实是这样的,就比如我的服务器的公网IP是43.143.132.22,这里用linux的ping命令也是能够ping通的。
但是如果我们将服务端设置的本地环回改为我的公网IP,此时当我们重新编译程序再次运行服务端的时候会发现服务端绑定失败。
由于云服务器的IP地址是由对应的云厂商提供的,这个IP地址并不一定是真正的公网IP,这个IP地址是不能直接被绑定的,如果需要让外网访问,此时我们需要bind 0。系统当当中提供的一个INADDR_ANY,这是一个宏值,它对应的值就是0。
因此如果我们需要让外网访问,那么在云服务器上进行绑定时就应该绑定INADDR_ANY,此时我们的服务器才能够被外网访问。
当一个服务器的带宽足够大时,一台机器接收数据的能力就约束了这台机器的IO效率,因此一台服务器底层可能装有多张网卡,此时这台服务器就可能会有多个IP地址,但一台服务器上端口号为8081的服务只有一个。这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据,如果这些数据也都想访问端口号为8081的服务。此时如果服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务端在接收数据的时候就只能从绑定IP对应的网卡接收数据。而如果服务端绑定的是INADDR_ANY,那么只要是发送给端口号为8081的服务的数据,系统都会可以将数据自底向上交给该服务端。
实际绑定案例:
class UdpServer
{
public:
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;
}
private:
int _sockfd; //文件描述符
int _port; //端口号
std::string _ip; //IP地址
};
6.8简易的回声服务器
服务端代码 |
---|
void Start()
{
#define SIZE 128
char buffer[SIZE];
for (;;){
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);
}
}
客户端代码 |
---|
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());
for (;;){
std::cout << "Please Enter# ";
getline(std::cin, msg);
sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
#define SIZE 128
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;
}
}
}
7.一个简单的tcp程序
原理上来说TCP和UDP的创建套接字和绑定步骤没有任何的区别(函数需要改改参数),因此我们在此将先介绍tcp与udp编程不同的地方
7.1listen函数(监听模式)
因为TCP服务器是面向连接的(udp是不面向连接的) 客户端在正式向TCP服务器发送数据之前需要建立连接
所以TCP服务器需要随时注意是否有客户端的连接请求 此时我们需要将状态设置为监听状态
int listen(int sockfd, int backlog);
参数说明:
sockfd:需要设置为监听状态的套接字对应的文件描述符。
backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
监听成功返回0,监听失败返回-1,同时错误码会被设置。
例子:
void Init()
{
_sockfd = socket(AF_INET, SOCK_STREAM , 0);
if (_sockfd < 0)
{
cout << "socket error" << endl;
exit(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(_sockfd , (struct sockaddr*)&local , sizeof(sockaddr)) < 0)
{
cout << "bind error" << endl;
exit(3);
}
if(listen(_sockfd , 5) < 0)
{
cout << "listen error" << endl;
exit(4);
}
}
我们在创建完套接字和绑定之后 需要再进一步将状态设置为监听状态 监听后续是否有新的连接 如果监听失败就意味着TCP无法接受服务器发送的请求了 此时服务器也没有了启动的意义 直接退出即可
7.2accept函数
在TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求,因此这里就需要用到我们的accept函数。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明:
sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。
返回值说明:
获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。
例子:
void Start()
{
for(;;)
{
struct sockaddr_in peer;
memset(&peer , 0 , sizeof(peer)); //清空里面的内容
socklen_t len = sizeof(peer);
int sock = accept(_sockfd , (struct sockaddr*)&peer , &len);
if (sock < 0)
{
cout << "accept error" << endl;
continue; // do not stop server
}
string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
cout << "get a new link " << sock << "new port is " << client_port <<endl ;
}
}
7.3connect函数
由于我们的客户端不需要绑定,监听 所以当创建完毕之后就可以开始请求链接了,我们使用connect函数来建立连接
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数解释:
1.sockfd:套接字描述符,用于标识要建立连接的套接字。
2.addr:指向服务器地址的结构体指针,指定要连接的服务器地址。
3.addrlen:服务器地址结构体的长度。
函数返回值为 0 表示连接成功,-1 表示连接失败。连接失败可能是由于目标地址不可达、连接超时或其他网络错误。
//使用案例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main() {
int sockfd;
struct sockaddr_in server_addr;
const char *server_ip = "127.0.0.1";
int server_port = 8080;
// 创建套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket");
exit(1);
}
// 设置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(server_port);
if (inet_pton(AF_INET, server_ip, &(server_addr.sin_addr)) <= 0) {
perror("inet_pton");
exit(1);
}
// 连接到服务器
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("connect");
exit(1);
}
printf("Connected to server\n");
// 在连接上进行数据传输...
// 关闭套接字
close(sockfd);
return 0;
}
总结起来,connect()
函数用于在 Linux 中建立 TCP 套接字的连接。它将客户端套接字连接到指定的服务器地址,连接成功后可以进行数据传输。
7.4服务端接收连接的测试
当我们的客户端connect之后,我们来做个测试看看当前服务器能否接收请求。
我们在服务器运行的时候传入一个端口号作为我们的服务端口 服务端初始化之后启动
编译代码后 我们使用8082端口号初始化服务器
它绑定的端口就是8082 而由于服务器绑定的是INADDR_ANY 因此该服务器的本地IP地址是0.0.0.0 这就意味着该TCP服务器可以读取本地任何一张网卡里面的数据。
我们可以使用telnet指令来登录当前服务器 因为itelntt指令底层就是使用tcp实现的
我们发现此时分配的文件描述符是4 这是因为在运行一个C++程序的时候默认会打开0 1 2 文件输入流 文件输出流 文件错误流
而3号文件描述符在初始化时分配给了监视套接字 因此当一个客户端发起连接请求的时候 为该客户端提供服务的文件套接字就是4
7.5服务器的请求处理问题
现在TCP服务器已经能够获取连接请求了我们可以对获取到的连接进行处理(accept返回的东西,这里我们称之为服务套接字)
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。
需要注意的是,服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#define BUFFER_SIZE 1024
#define PORT 8080
int main() {
int sockfd, newsockfd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len;
char buffer[BUFFER_SIZE];
const char *message = "Hello, client!";
// 创建套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket");
exit(1);
}
// 设置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
// 绑定套接字到服务器地址
if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
exit(1);
}
// 监听连接请求
if (listen(sockfd, 5) == -1) {
perror("listen");
exit(1);
}
printf("Server listening on port %d\n", PORT);
while (1) {
// 接受客户端连接
client_len = sizeof(client_addr);
newsockfd = accept(sockfd, (struct sockaddr *)&client_addr, &client_len);
if (newsockfd == -1) {
perror("accept");
exit(1);
}
printf("Client connected\n");
// 向客户端发送消息
if (write(newsockfd, message, strlen(message)) == -1) {
perror("write");
exit(1);
}
// 从客户端接收消息
ssize_t num_bytes = read(newsockfd, buffer, BUFFER_SIZE - 1);
if (num_bytes == -1) {
perror("read");
exit(1);
}
buffer[num_bytes] = '\0';
printf("Received message from client: %s\n", buffer);
// 关闭与客户端的连接
close(newsockfd);
}
// 关闭服务器套接字
close(sockfd);
return 0;
}
7.6项目的完整代码
7.61封装 TCP socket
#pragma once
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <string>
#include <cassert>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
#define CHECK_RET(exp) if (!(exp)) {\
return false;\
}
class TcpSocket {
public:
TcpSocket() : fd_(-1) { }
TcpSocket(int fd) : fd_(fd) { }
bool Socket() {
fd_ = socket(AF_INET, SOCK_STREAM, 0);
if (fd_ < 0) {
perror("socket");
return false;
}
printf("open fd = %d\n", fd_);
return true;
}
bool Close() const {
close(fd_);
printf("close fd = %d\n", fd_);
return true;
}
bool Bind(const std::string& ip, uint16_t port) const {
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str());
addr.sin_port = htons(port);
int ret = bind(fd_, (sockaddr*)&addr, sizeof(addr));
if (ret < 0) {
perror("bind");
return false;
}
return true;
}
bool Listen(int num) const {
int ret = listen(fd_, num);
if (ret < 0) {
perror("listen");
return false;
}
return true;
}
bool Accept(TcpSocket* peer, std::string* ip = NULL, uint16_t* port = NULL) const {
sockaddr_in peer_addr;
socklen_t len = sizeof(peer_addr);
int new_sock = accept(fd_, (sockaddr*)&peer_addr, &len);
if (new_sock < 0) {
perror("accept");
return false;
}
printf("accept fd = %d\n", new_sock);
peer->fd_ = new_sock;
if (ip != NULL) {
*ip = inet_ntoa(peer_addr.sin_addr);
}
if (port != NULL) {
*port = ntohs(peer_addr.sin_port);
}
return true;
}
bool Recv(std::string* buf) const {
buf->clear();
char tmp[1024 * 10] = { 0 };
// [注意!] 这里的读并不算很严谨, 因为一次 recv 并不能保证把所有的数据都全部读完
// 参考 man 手册 MSG_WAITALL 节.
ssize_t read_size = recv(fd_, tmp, sizeof(tmp), 0);
if (read_size < 0) {
perror("recv");
return false;
}
if (read_size == 0) {
return false;
}
buf->assign(tmp, read_size);
return true;
}
bool Send(const std::string& buf) const {
ssize_t write_size = send(fd_, buf.data(), buf.size(), 0);
if (write_size < 0) {
perror("send");
return false;
}
return true;
}
bool Connect(const std::string& ip, uint16_t port) const {
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str());
addr.sin_port = htons(port);
int ret = connect(fd_, (sockaddr*)&addr, sizeof(addr));
if (ret < 0) {
perror("connect");
return false;
}
return true;
}
int GetFd() const {
return fd_;
}
private:
int fd_;
};
7.62TCP通用服务器
tcp_server.hpp
#pragma once
#include <functional>
#include "tcp_socket.hpp"
typedef std::function<void(const std::string& req, std::string* resp)> Handler;
class TcpServer {
public:
TcpServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
}
bool Start(Handler handler) {
// 1. 创建 socket;
CHECK_RET(listen_sock_.Socket());
// 2. 绑定端口号
CHECK_RET(listen_sock_.Bind(ip_, port_));
// 3. 进行监听
CHECK_RET(listen_sock_.Listen(5));
// 4. 进入事件循环
for (;;) {
// 5. 进行 accept
TcpSocket new_sock;
std::string ip;
uint16_t port = 0;
if (!listen_sock_.Accept(&new_sock, &ip, &port)) {
continue;
}
printf("[client %s:%d] connect!\n", ip.c_str(), port);
// 6. 进行循环读写
for (;;) {
std::string req;
// 7. 读取请求. 读取失败则结束循环
bool ret = new_sock.Recv(&req);
if (!ret) {
printf("[client %s:%d] disconnect!\n", ip.c_str(), port);
// [注意!] 需要关闭 socket
new_sock.Close();
break;
}
// 8. 计算响应
std::string resp;
handler(req, &resp);
// 9. 写回响应
new_sock.Send(resp);
printf("[%s:%d] req: %s, resp: %s\n", ip.c_str(), port,
req.c_str(), resp.c_str());
}
}
return true;
}
private:
TcpSocket listen_sock_;
std::string ip_;
uint64_t port_;
};
7.63英译汉服务器
#include <unordered_map>
#include "tcp_server.hpp"
std::unordered_map<std::string, std::string> g_dict;
void Translate(const std::string& req, std::string* resp) {
auto it = g_dict.find(req);
if (it == g_dict.end()) {
*resp = "未找到";
return;
}
*resp = it->second;
return;
}
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage ./dict_server [ip] [port]\n");
return 1;
}
// 1. 初始化词典
g_dict.insert(std::make_pair("hello", "你好"));
g_dict.insert(std::make_pair("world", "世界"));
g_dict.insert(std::make_pair("bit", "贼NB"));
// 2. 启动服务器
TcpServer server(argv[1], atoi(argv[2]));
server.Start(Translate);
return 0;
}
7.64TCP通用客户端
tcp_client.hpp
#pragma once
#include "tcp_socket.hpp"
class TcpClient {
public:
TcpClient(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
// [注意!!] 需要先创建好 socket
sock_.Socket();
}
~TcpClient() {
sock_.Close();
}
bool Connect() {
return sock_.Connect(ip_, port_);
}
bool Recv(std::string* buf) {
return sock_.Recv(buf);
}
bool Send(const std::string& buf) {
return sock_.Send(buf);
}
private:
TcpSocket sock_;
std::string ip_;
uint16_t port_;
};
7.65英译汉客户端
dict_client.cc
#include "tcp_client.hpp"
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage ./dict_client [ip] [port]\n");
return 1;
}
TcpClient client(argv[1], atoi(argv[2]));
bool ret = client.Connect();
if (!ret) {
return 1;
}
for (;;) {
std::cout << "请输入要查询的单词:" << std::endl;
std::string word;
std::cin >> word;
if (!std::cin) {
break;
}
client.Send(word);
std::string result;
client.Recv(&result);
std::cout << result << std::endl;
}
return 0;
}
8.各种类型的tcp服务器
8.1前置小结
在讲解之前我们先回顾一下前面的执行步骤。
启动服务器之后启动客户端1然后在启动客户端2
我们发现启动客户端1之后向服务器发送数据服务器很快的就回显了一个数据并且打印了得到一个新连接
可是在客户端2连接的时候却没有发生任何情况
当我们的客户端1退出的时候 服务器接受到了客户端2的连接并且回显了数据
因为我们写的服务器是单执行流的,一次只能服务一个客户端
服务器是处于监听状态的 在我们的客户端2发送连接请求的时候实际上已经被监听到了 只不过服务端没有调用accept函数将该连接获取上来
实际在底层会为我们维护一个连接队列 服务端没有accept的新连接就会放到这个连接队列当中 而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的 因此服务端虽然没有获取第二个客户端发来的连接请求 但是在第二个客户端那里显示是连接成功的
为了解决这一问题我们下面将引进多执行流的编写方案。
8.2多进程版本的tcp服务器
当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务 而是当前执行流调用fork函数创建子进程 然后让子进程为父进程获取到的连接提供服务
由于父子进程是两个不同的执行流 当父进程调用fork创建出子进程后 父进程就可以继续从监听套接字当中获取新连接 而不用关心获取上来的连接是否服务完毕
额外知识补充:
当父进程打开了一个文件 ,该文件对应的文件描述符是3 此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件, 而如果子进程再创建一个子进程 那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件。
但是当父进程创建出子进程之后父子进程就会保持独立性了, 此时父进程文件描述符表的变化不会影响子进程的文件描述符表,同样子进程文件描述符的变化也不会影响父进程。
等待子进程问题 |
---|
当父进程创建出子进程后 父进程是需要等待子进程退出的 否则子进程会变成僵尸进程 进而造成内存泄漏
因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待
此时我们就有两种等待方式 阻塞式等待和非阻塞式等待:
-
如果服务端采用阻塞的方式等待子进程 那么服务端还是需要等待服务完当前客户端 才能继续获取下一个连接请求此时服务端仍然是以一种串行的方式为客户端提供服务
-
如果服务端采用非阻塞的方式等待子进程 虽然在子进程为客户端提供服务期间服务端可以继续获取新连接 但此时服务端就需要将所有子进程的PID保存下来 并且需要不断花费时间检测子进程是否退出
总之 服务端要等待子进程退出 无论采用阻塞式等待还是非阻塞式等待 都不尽人意 此时我们可以考虑让服务端不等待子进程退出
当父进程创建出子进程后 父进程是需要等待子进程退出的 否则子进程会变成僵尸进程 进而造成内存泄漏
因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待
此时我们就有两种等待方式 阻塞式等待和非阻塞式等待:
- 如果服务端采用阻塞的方式等待子进程 那么服务端还是需要等待服务完当前客户端 才能继续获取下一个连接请求此时服务端仍然是以一种串行的方式为客户端提供服务
- 如果服务端采用非阻塞的方式等待子进程 虽然在子进程为客户端提供服务期间服务端可以继续获取新连接 但此时服务端就需要将所有子进程的PID保存下来 并且需要不断花费时间检测子进程是否退出
总之 服务端要等待子进程退出 无论采用阻塞式等待还是非阻塞式等待 都不尽人意 此时我们可以考虑让服务端不等待子进程退出
当父进程创建出子进程后 父进程是需要等待子进程退出的 否则子进程会变成僵尸进程 进而造成内存泄漏
因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待
此时我们就有两种等待方式 阻塞式等待和非阻塞式等待:
- 如果服务端采用阻塞的方式等待子进程 那么服务端还是需要等待服务完当前客户端 才能继续获取下一个连接请求此时服务端仍然是以一种串行的方式为客户端提供服务
- 如果服务端采用非阻塞的方式等待子进程 虽然在子进程为客户端提供服务期间服务端可以继续获取新连接 但此时服务端就需要将所有子进程的PID保存下来 并且需要不断花费时间检测子进程是否退出
总之 服务端要等待子进程退出 无论采用阻塞式等待还是非阻塞式等待 都不尽人意 此时我们可以考虑让服务端不等待子进程退出
当子进程退出时会给父进程发送SIGCHLD信号 如果父进程将SIGCHLD信号进行捕捉 并将该信号的处理动作设置为忽略 此时父进程就只需专心处理自己的工作 不必关心子进程了
下面是实际例子:
class TcpServer
{
public:
void Start()
{
signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信号
for (;;){
//获取连接
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); //子进程提供完服务退出
}
}
}
private:
int _listen_sock; //监听套接字
int _port; //端口号
};
让孙子进程执行任务 |
---|
我们也可以让服务端创建出来的子进程再次进行fork 让孙子进程为客户端提供服务 此时我们就不用等待孙子进程退出了。
命名:
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程
- 爸爸进程:由爷爷进程调用fork函数创建出来的进程
- 孙子进程:由爸爸进程调用fork函数创建出来的进程 该进程调用Service函数为客户端提供服务
我们让爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功 ,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。
这里主要是利用了孤儿进程的原理 当孙子进程的父进程死亡后它就会被1号进程也就是init进程领养 当孙子进程运行完毕之后它的资源会由1号进程进行回收 我们也就不需要担心僵尸进程的问题了。
实际代码操作:
class TcpServer
{
public:
void Start()
{
for (;;){
//获取连接
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
close(_listen_sock); //child关闭监听套接字
if (fork() > 0){
exit(0); //爸爸进程直接退出
}
//处理请求
Service(sock, client_ip, client_port); //孙子进程提供服务
exit(0); //孙子进程提供完服务退出
}
close(sock); //father关闭为连接提供服务的套接字
waitpid(id, nullptr, 0); //等待爸爸进程(会立刻等待成功)
}
}
private:
int _listen_sock; //监听套接字
int _port; //端口号
};
我们可以发现当前服务器可以支持多个客户端访问并且得到的文件描述符都是4
8.3多线程版本的tcp服务器
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现
当服务进程调用accept函数获取到一个新连接后 就可以直接创建一个线程 让该线程为对应客户端提供服务
当然 主线程(服务进程)创建出新线程后 也是需要等待新线程退出的 否则也会造成类似于僵尸进程这样的问题 但对于线程来说 如果不想让主线程等待新线程退出 可以让创建出来的新线程调用pthread_detach函数进行线程分离 当这个线程退出时系统会自动回收该线程所对应的资源 此时主线程(服务进程)就可以继续调用accept函数获取新连接 而让新线程去服务对应的客户端
各个线程共享同一张文件描述符表 |
---|
文件描述符表维护的是进程与文件之间的对应关系 因此一个进程对应一张文件描述符表
而主线程创建出来的新线程依旧属于这个进程 因此创建线程时并不会为该线程创建独立的文件描述符表 所有的线程看到的都是同一张文件描述符表
因此当服务进程(主线程)调用accept函数获取到一个文件描述符后 其他创建的新线程是能够直接访问这个文件描述符的
需要注意的是 虽然新线程能够直接访问主线程accept上来的文件描述符 但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符
因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符的值 也就是告诉每个新线程在服务客户端时 应该对哪一个套接字进行操作
文件描述符关闭的问题 |
---|
由于此时所有线程看到的都是同一张文件描述符表 因此当某个线程要对这张文件描述符表做某种操作时 不仅要考虑当前线程 还要考虑其他线程
- 对于主线程accept上来的文件描述符 主线程不能对其进行关闭操作 该文件描述符的关闭操作应该由新线程来执行 因为是新线程为客户端提供服务的 只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭
- 对于监听套接字 虽然创建出来的新线程不必关心监听套接字 但新线程不能将监听套接字对应的文件描述符关闭 否则主线程就无法从监听套接字当中获取新连接了
Service函数定义为静态成员函数 |
---|
由于调用pthread_create函数创建线程时 新线程的执行例程是一个参数为void* 返回值为void*的函数 如果我们要将这个执行例程定义到类内 就需要将其定义为静态成员函数 否则这个执行例程的第一个参数是隐藏的this指针
在线程的执行例程当中会调用Service函数 由于执行例程是静态成员函数 静态成员函数无法调用非静态成员函数 因此我们需要将Service函数定义为静态成员函数 恰好Service函数内部进行的操作都是与类无关的 因此我们直接在Service函数前面加上一个static即可
Rontine函数:
static void* Rontine(void* arg)
{
pthread_detach(pthread_self());
int* p = (int*)arg;
int sock = *p;
Service(sock);
return nullptr;
}
Start函数:
void Start()
{
while(true)
{
// accept
struct sockaddr_in peer;
memset(&peer , '\0' , sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_sockfd , (struct sockaddr*)&peer , &len);
if (sock < 0)
{
cout << "accept error" << endl;
continue;
}
int* p = &sock;
pthread_t tid;
pthread_create(&tid , nullptr , Rontine , (void*)p);
}
}
8.4线程池版多线程TCP网络程序
当前多线程版的服务器存在的问题:
- 每当有新连接到来时 服务端的主线程都会重新为该客户端创建为其提供服务的新线程 而当服务结束后又会将该新线程销毁 这样做不仅麻烦 而且效率低下 每当连接到来的时候服务端才创建对应提供服务的线程
- 如果有大量的客户端连接请求 此时服务端要为每一个客户端创建对应的服务线程 计算机当中的线程越多 CPU的压力就越大 因为CPU要不断在这些线程之间来回切换 此时CPU在调度线程的时候 线程和线程之间切换的成本就会变得很高
- 一旦线程太多 每一个线程再次被调度的周期就变长了 而线程是为客户端提供服务的 线程被调度的周期变长 客户端也迟迟得不到应答
解决思路 |
---|
- 可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程。
- 当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒。
- 服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大。此外,如果有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务。
我们可以发现 我们前面做的线程池可以完美解决上面的问题
服务类新增线程池成员 |
---|
服务类新增线程池成员
- 当实例化服务器对象时,先将这个线程池指针先初始化为空。
- 当服务器初始化完毕后,再实际构造这个线程池对象,在构造线程池对象时可以指定线程池当中线程的个数,也可以不指定,此时默认线程的个数为5。
- 在启动服务器之前对线程池进行初始化,此时就会将线程池当中的若干线程创建出来,而这些线程创建出来后就会不断检测任务队列,从任务队列当中拿出任务进行处理。
现在当服务进程调用accept函数获取到一个连接请求后,就会根据该客户端的套接字、IP地址以及端口号构建出一个任务,然后调用线程池提供的Push接口将该任务塞入任务队列
这实际也是一个生产者消费者模型,其中服务进程就作为了任务的生产者,而后端线程池当中的若干线程就不断从任务队列当中获取任务进行处理,它们承担的就是消费者的角色,其中生产者和消费者的交易场所就是线程池当中的任务队列。
void Start()
{
_tp->ThreadPoolInit();
while(true)
{
// accept
struct sockaddr_in peer;
memset(&peer , '\0' , sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_sockfd , (struct sockaddr*)&peer , &len);
if (sock < 0)
{
cout << "accept error" << endl;
continue;
}
Task task(port);
_tp->Push(task);
}
}
设计任务类 |
---|
现在我们要做的就是设计一个任务类,该任务类当中需要包含客户端对应的套接字、IP地址、端口号,表示该任务是为哪一个客户端提供服务,对应操作的套接字是哪一个。
此外,任务类当中需要包含一个Run方法,当线程池中的线程拿到任务后就会直接调用这个Run方法对该任务进行处理,而实际处理这个任务的方法就是服务类当中的Service函数,服务端就是通过调用Service函数为客户端提供服务的。
我们可以直接拿出服务类当中的Service函数,将其放到任务类当中作为任务类当中的Run方法,但这实际不利于软件分层。我们可以给任务类新增一个仿函数成员,当执行任务类当中的Run方法处理任务时就可以以回调的方式处理该任务。
Handler类:
class Handler
{
Handler() = default;
void operator()(int sock)
{
cout << "get a new linl : " << sock << endl;
char buff[1024];
while(true)
{
ssize_t size = read(sock , buff , sizeof(buff) - 1);
if (size > 0)
{
buff[size] = 0; // '\0'
cout << buff << endl;
write(sock , buff , size);
}
else if (size == 0)
{
cout << "read close" << endl;
break;
}
else
{
cout << "unknown error" << endl;
}
}
close(sock);
cout << "Service end sock closed" << endl;
}
};
Task类:
#pragma once
#include "sever.cc"
#include <iostream>
using namespace std;
class Task
{
private:
int _sock;
Handler _handler;
public:
Task(int sock)
:_sock(sock)
{}
Task() = default;
void run()
{
_handler(_sock);
}
};
9.TCP协议通讯流程
通讯流程总览
下图是基于TCP协议的客户端/服务器程序的一般流程:
下面我们结合TCP协议的通信流程 来初步认识一下三次握手和四次挥手 以及建立连接和断开连接与各个网络接口之间的对应关系
三次握手
初始化服务器 |
---|
当服务器完成套接字创建、绑定以及监听的初始化动作之后,就可以调用accept函数阻塞等待客户端发起请求连接了服务器初始化:
- 调用socket,创建文件描述符。
- 调用bind,将当前的文件描述符和IP/PORT绑定在一起,如果这个端口已经被其他进程占用了,就会bind失败。
- 调用listen,声明当前这个文件描述符作为一个服务器的文件描述符,为后面的accept做好准备。
- 调用accept,并阻塞,等待客户端连接到来。
建立连接 |
---|
而客户端在完成套接字创建后,就会在合适的时候通过connect函数向服务器发起连接请求,而客户端在connect的时候本质是通过某种方式向服务器三次握手,因此connect的作用实际就是触发三次握手。
建立连接的过程:
- 调用socket,创建文件描述符。
- 调用connect,向服务器发起连接请求。
- connect会发出SYN段并阻塞等待服务器应答(第一次)。
- 服务器收到客户端的SYN,会应答一个SYN-ACK段表示“同意建立连接”(第二次)。
- 客户端收到SYN-ACK后会从connect返回,同时应答一个ACK段(第三次)
这个建立连接的过程,通常称为三次握手。
需要注意的是,连接并不是立马建立成功的,由于TCP属于传输层协议,因此在建立连接时双方的操作系统会自主进行三次协商,最后连接才会建立成功。
数据传输的过程
数据交互 |
---|
连接一旦建立成功并且被accept获取上来后,此时客户端和服务器就可以进行数据交互了。需要注意的是,连接建立和连接被拿到用户层是两码事,accept函数实际不参与三次握手这个过程,因为三次握手本身就是底层TCP所做的工作。accept要做的只是将底层已经建立好的连接拿到用户层,如果底层没有建立好的连接,那么accept函数就会阻塞住直到有建立好的连接。
而双方在进行数据交互时使用的实际就是read和write,其中write就叫做写数据,read就叫做读数据。write的任务就是把用户数据拷贝到操作系统,而拷贝过去的数据何时发以及发多少,就是由TCP决定的。而read的任务就是把数据从内核读到用户。
数据传输的过程:
- 建立连接后,TCP协议提供全双工的通信服务,所谓全双工的意思是,在同一条连接中,同一时刻,通信双方可以同时写数据,相对的概念叫做半双工,同一条连接在同一时刻,只能由一方来写数据。
- 服务器从accept返回后立刻调用read,读socket就像读管道一样,如果没有数据到达就阻塞等待。
- 这时客户端调用write发送请求给服务器,服务器收到后从read返回,对客户端的请求进行处理,在此期间客户端调用read阻塞等待服务器端应答。
- 服务器调用write将处理的结果发回给客户端,再次调用read阻塞等待下一条请求。
- 客户端收到后从read返回,发送下一条请求,如此循环下去。
四次挥手的过程
端口连接 |
---|
当双方通信结束之后,需要通过四次挥手的方案使双方断开连接,当客户端调用close关闭连接后,服务器最终也会关闭对应的连接。而其中一次close就对应两次挥手,因此一对close最终对应的就是四次挥手。
断开连接的过程:
- 如果客户端没有更多的请求了,就调用close关闭连接,客户端会向服务器发送FIN段(第一次)。
- 此时服务器收到FIN后,会回应一个ACK,同时read会返回0(第二次)。
- read返回之后,服务器就知道客户端关闭了连接,也调用close关闭连接,这个时候服务器会向客户端发送一个FIN(第三次)。
- 客户端收到FIN,再返回一个ACK给服务器(第四次)。
这个断开连接的过程,通常称为四次挥手。
注意通讯流程与socket API之间的对应关系 |
---|
在学习socket API时要注意应用程序和TCP协议是如何交互的:
- 应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect会发出SYN段。
- 应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read返回0就表明收到了FIN段。
为什么要断开连接? |
---|
建立连接本质上是为了保证通信双方都有专属的连接,这样我们就可以加入很多的传输策略,从而保证数据传输的可靠性。但如果双方通信结束后不断开对应的连接,那么系统的资源就会越来越少。
因为服务器是会收到大量连接的,操作系统必须要对这些连接进行管理,在管理连接时我们需要“先描述再组织”。因此当一个连接建立后,在服务端就会为该连接维护对应的数据结构,并且会将这些连接的数据结构组织起来,此时操作系统对连接的管理就变成了对链表的增删查改。
如果一个连接建立后不断开,那么操作系统就需要一直为其维护对应的数据结构,而维护这个数据结构是需要花费时间和空间的,因此当双方通信结束后就应该将这个连接断开,避免系统资源的浪费,这其实就是TCP比UDP更复杂的原因之一,因为TCP需要对连接进行管理。
TCP/UDP
- 可靠传输 vs 不可靠传输
- 有连接 vs 无连接
- 字节流 vs 数据报