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

网络套接字编程

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_instruct 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 128char 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 128char 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 128char 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:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为510即可。返回值说明:
监听成功返回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 8080int 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. 进行 acceptTcpSocket 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);// [注意!] 需要关闭 socketnew_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) {// [注意!!] 需要先创建好 socketsock_.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){ //childclose(_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 数据报

相关文章:

网络套接字编程

1.基础预备知识 1.1源ip和目的ip 在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址 源IP地址表示发起通信的设备的IP地址。它是数据包的出发点&#xff0c;标识了数据包的来源。当一个设备发送数据包到网络上的其他设备时&#xff0c;该数据包的源IP字段会被…...

Node编写更新用户信息接口

目录 前言 定义路由和处理函数 验证表单数据 实现更新用户基本信息的功能 前言 继前面几篇文章&#xff0c;本文介绍如何编写更新用户信息接口 定义路由和处理函数 路由 // 更新用户信息接口 router.post(/userinfo, userinfo_handler.updateUserinfo) 处理函数 // 导…...

Delphi解决 openssl DLL 与 Indy 的SSL/TLS 连接问题

昨天&#xff0c;突然间&#xff0c;我的一个 Delphi 程序无法连接到互联网上的各种WMS服务器。我收到以下错误消息&#xff1a; 使用 SSL 连接时出错。错误 1409442E&#xff1a;SSL 例程&#xff1a;ssl3_read_bytes&#xff1a;tlsv1 警报协议版本 由于我使用的是最新版本…...

单片机仿真设计打包项目

小伙伴们在仿真设计时会遇到各种各样的问题&#xff0c;网上的资料可能不全或者很贵。 这篇也不单纯为了打广告&#xff0c;主要是希望实实在在帮到学单片机的同学&#xff0c;大家不要一有问题就各种找dai zuo&#xff0c;做的好不好是一回事儿&#xff0c;关键是它费&#x…...

Java练习题-输出二维数组对角线元素和

✅作者简介&#xff1a;CSDN内容合伙人、阿里云专家博主、51CTO专家博主、新星计划第三季python赛道Top1&#x1f3c6; &#x1f4c3;个人主页&#xff1a;hacker707的csdn博客 &#x1f525;系列专栏&#xff1a;Java练习题 &#x1f4ac;个人格言&#xff1a;不断的翻越一座又…...

Python调用ctype的动态库时出现的问题记录

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 前言一、动态库调用问题1.问题发现2.解决问题 总结 前言 提示&#xff1a;这里可以添加本文要记录的大概内容&#xff1a; 之前用公司算法同事写的c算法编译成的d…...

面试算法38:每日温度

题目 输入一个数组&#xff0c;它的每个数字是某天的温度。请计算每天需要等几天才会出现更高的温度。例如&#xff0c;如果输入数组[35&#xff0c;31&#xff0c;33&#xff0c;36&#xff0c;34]&#xff0c;那么输出为[3&#xff0c;1&#xff0c;1&#xff0c;0&#xff…...

流程控制语句中的顺序结构、分支结构和循环结构以及示例和详细代码解释为什么这样写(1)

在流程控制语句中&#xff0c;我们通常可以将其分为三种基本结构&#xff1a;顺序结构、分支结构和循环结构。 1. 顺序结构&#xff1a;顺序结构是最简单的流程控制结构&#xff0c;代码按照编写的顺序依次执行&#xff0c;没有条件或循环的干扰。下面是一个顺序结构的示例代码…...

MFC Windows 程序设计[334]之自定义编辑框(附源码)

MFC Windows 程序设计[334]之自定义编辑框 程序之美前言主体运行效果核心代码逻辑分析结束语程序之美 前言 MFC是微软公司提供的一个类库(class libraries),以C++类的形式封装了Windows API,并且包含一个应用程序框架,以减少应用程序开发人员的工作量。其中包含大量Wind…...

MOS管特性及其几种常用驱动电路详解,电子工程师手把手教你

在电子工程中&#xff0c;MOS管&#xff08;金属氧化物半导体场效应管&#xff09;是一种非常重要的半导体元件。 在这篇文章中&#xff0c;我们将深入探讨MOS管的特性&#xff0c;以及几种常用的驱动电路的工作原理和设计方法。无论你是初学者还是经验丰富的电子工程师&#…...

C#,数值计算——分类与推理Phylo_wpgma的计算方法与源程序

1 文本格式 using System; using System.Collections.Generic; namespace Legalsoft.Truffer { public class Phylo_wpgma : Phylagglom { public override void premin(double[,] d, int[] nextp) { } public override double dminfn(double…...

Spring MVC 常用的注解

Controller&#xff1a;用于将一个类标记为 Spring MVC 控制器&#xff0c;处理 HTTP 请求和生成 HTTP 响应。RestController&#xff1a;类似于 Controller&#xff0c;但返回的数据会被自动转换为 JSON 或 XML 格式&#xff0c;通常用于构建 RESTful API。等于Controller Re…...

winodos下使用VS2022编译eclipse-paho.mqtt.c并演示简单使用的 demo

本文演示C语言如何使用eclipse-paho.mqtt.c库&#xff0c;包含自行编译库的步骤或者下载编译好的文件。 1.下载paho.mqtt.c库源码&#xff08;zip 文件&#xff09; 到官网选择C版本的paho源码进行下载 Eclipse Paho | The Eclipse Foundation 或者到下述连接下载 Releases ec…...

【Java 进阶篇】使用 Java 和 Jsoup 进行 XML 处理

XML&#xff08;可扩展标记语言&#xff09;是一种常用的数据交换格式&#xff0c;它被广泛用于在不同系统之间传递和存储数据。Java作为一种强大的编程语言&#xff0c;提供了多种方式来处理XML数据。其中&#xff0c;Jsoup 是一个流行的Java库&#xff0c;用于解析和操作XML文…...

QT图形视图框架绘制曲线图和Smith图

QT图形视图框架绘制曲线图和Smith图 QGraphicsView是Qt框架中的一个图形视图部件&#xff0c;用于显示和处理2D图形元素。它提供了强大的工具来创建交互式和自定义的图形应用程序。在绘制折线图和Smith图时&#xff0c;使用QGraphicsView有以下一些优点&#xff1a; 交互性&am…...

0032【Edabit ★☆☆☆☆☆】【每秒帧数】Frames Per Second

0032【Edabit ★☆☆☆☆☆】【每秒帧数】Frames Per Second algorithms language_fundamentals math numbers Instructions Create a function that returns the number of frames shown in a given number of minutes for a certain FPS. Examples frames(1, 1) // 60 fra…...

【LeetCode】1423 可获得的最大点数(中等题)

【题目描述】 几张卡牌 排成一行&#xff0c;每张卡牌都有一个对应的点数。点数由整数数组 cardPoints 给出。 每次行动&#xff0c;你可以从行的开头或者末尾拿一张卡牌&#xff0c;最终你必须正好拿 k 张卡牌。 你的点数就是你拿到手中的所有卡牌的点数之和。 给你一个整数…...

2024年天津中德应用技术大学专升本物流管理专业课考试大纲

天津中德应用技术大学物流管理专业&#xff08;高职升本科&#xff09;2024年专业基础考试大纲 一、试卷类型 物流管理专业升本专业课考试共1套试卷&#xff0c;总分200分&#xff0c;考试时间为2小时。内容包含仓储与配送管理40%、物流基础30%&#xff0c;运输管理30%&#…...

目标检测YOLO实战应用案例100讲-船舶目标检测及编队识别(续)

目录 3.2.3 分类与回归 3.2.4 网络预设置与训练策略 3.3 实验与分析 3.3.1 实验环境 3.3.2...

【MySQL索引与优化篇】索引的数据结构

文章目录 1. 概述2. 常见索引结构2.1 聚簇索引2.2 二级索引(辅助索引、非聚簇索引)2.3 联合索引 3. InnoDB的B树索引的注意事项3.1 根页面位置万年不动3.2 内节点中目录项记录的唯一性 4. MyISAM中的索引方案5. InnoDB和MyISAM对比6. 小结7. 补充&#xff1a;MySQL数据结构的合…...

Qt Widget 删除之后还会显示 问题

目录 问题描述&#xff1a;Qt QWidget 删除之后还会显示 解决方案&#xff1a; Part1: 使用 deleteLater Part2: 使用 setParent(nullptr) 父控件为空 还有一种不常用的方法 隐藏&#xff1a; 问题描述&#xff1a;Qt QWidget 删除之后还会显示 Qt 无论使用 while (Layo…...

关系型数据库的问题和NoSQL数据库的应用

1.关系型数据库的问题 系统使用通用的商用关系型数据库&#xff0c;系统内部数据采用中央集中方式存储。系统投入使用后&#xff0c;初期用户数量少&#xff0c;系统运行平稳。一段时间后&#xff0c;用户数出现了爆炸式增长&#xff0c;系统暴露出诸多问题&#xff0c;集中表…...

二进制安装k8s

192.168.11.12 master01 192.168.11.12 y4 node01 192.168.11.14 y5 node02 192.168.11.15 对环境进行初始化&#xff0c;主机192.168.11.12、主机y4、主机y5&#xff0c;三台主机都要做以下操作&#xff0c;唯一不同的就是修改主…...

超简洁ubuntu linux 安装 cp2k

文章目录 打开下载网址解压接下来的步骤讲解 将解压的包移到对应路径下最后运行 打开下载网址 需要从github下载&#xff1a;下载网址 两个都可以从windows下先下载&#xff0c;再复制到linux中&#xff0c; 如果不能复制&#xff0c;右键这两个&#xff0c;复制链接&#xf…...

判断日期区间或季节等

使用JavaScript的Date对象来获取当前日期&#xff0c;并通过比较判断是否在指定的日期范围内&#xff08;如3月16日-9月15日&#xff09;。以下是一个示例代码&#xff1a; var currentDate new Date(); // 获取当前日期 var startRange new Date(currentDate.getFullYear()…...

入门人工智能 —— 学习数据持久化、使用 Python 将数据保存到mysql(7)

入门人工智能 —— 学习数据持久化、使用 Python 将数据保存到mysql 什么是数据持久化&#xff1f;使用 Python 进行数据持久化步骤 1: 安装 MySQL步骤 2: 安装必要的 Python 库步骤 3: 连接到 MySQL 数据库步骤 4: 创建数据表步骤 5: 插入数据步骤 6: 查询数据步骤 7: 关闭连接…...

c语言从入门到实战——分支和循环

分支和循环 前言1. if语句1.1 if1.2 else1.3 分支中包含多条语句1.4 嵌套if1.5 悬空else问题 2. 关系操作符3. 条件操作符4. 逻辑操作符&#xff1a;&& , || , &#xff01;4.1 逻辑取反运算符4.2 与运算符4.3 或运算符4.4 练习&#xff1a;闰年的判断4.5 短路 5. swit…...

交易所(Exchange, ACM/ICPC NEERC 2006, UVa1598)rust解法

你的任务是为交易所设计一个订单处理系统。要求支持以下3种指令。 BUY p q&#xff1a;有人想买&#xff0c;数量为p&#xff0c;价格为q。 SELL p q&#xff1a;有人想卖&#xff0c;数量为p&#xff0c;价格为q。 CANCEL i&#xff1a;取消第i条指令对应的订单&#xff08;输…...

shell_51.Linux获取用户输入_无显示读取,从文件中读取

无显示读取 有时你需要从脚本用户处得到输入&#xff0c;但又不想在屏幕上显示输入信息。典型的例子就是输入密码&#xff0c;但除此之外还有很多种需要隐藏的数据。 -s 选项可以避免在 read 命令中输入的数据出现在屏幕上&#xff08;其实数据还是会被显示&#xff0c;只不过 …...

NOIP2023模拟2联测23 集训

题目大意 给定 n n n个数 a 1 , a 2 , ⋯ , a n a_1,a_2,\cdots,a_n a1​,a2​,⋯,an​&#xff0c;你需要找到一个集合 S S S&#xff0c;使得 S S S中严格大于 S S S的平均数的数字个数尽量多&#xff0c;输出最多的个数。 注意&#xff1a;这里的集合是可重集&#xff0c;…...