接着上一篇文章:http://t.csdnimg.cn/GZDlI
在上一篇文章中,我们实现的是UDP协议的,今天我们就要来实现一下TCP版本的
接下来接下来实现一批基于 TCP 协议的网络程序,本节只介绍基于IPv4的socket网络编程
基于 TCP 的网络编程开发分为服务器端和客户端两部分,常见的核心步骤和流程如下:
1.TCP网络通信程序
1.1.程序结构
分别实现客户端与服务器,客户端向服务器发送消息,服务器收到消息后,回响给客户端,有点类似于 echo
指令
这个程序我们已经基于 UDP
协议实现过了,换成 TCP
协议实现时,程序的结构是没有变化的,同样需要 server.hpp
、server.cc
、client.hpp
、client.cc
这几个文件
创建
server.hpp
服务器头文件
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>namespace nt_server
{const uint16_t default_port = 8877; // 默认端口号const std::string default_ip ="0.0.0.0";//默认IPenum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR};class TcpServer{public:TcpServer( const uint16_t port = default_port,const std::string ip = default_ip):ip_(ip),port_(port){}~TcpServer(){}// 初始化服务器void InitServer(){}// 启动服务器void StartServer(){}private:int sock_; // 套接字(存疑)uint16_t port_; // 端口号std::string ip_;//ip地址};
}
创建
server.cc
服务器源文件
#include <string>
#include <vector>
#include <memory> // 智能指针相关头文件
#include <cstdio>
#include "server.hpp"using namespace std;
using namespace nt_server;//业务处理函数
std::string ExecCommand(const std::string& request)
{ return request;
}void Usage(const char* program)
{cout << "Usage:" << endl;cout << "\t" << program << " ServerPort" << endl;
}int main(int argc, char* argv[])
{if (argc != 2){// 错误的启动方式,提示错误信息Usage(argv[0]);return USAGE_ERR;}//命令行参数都是字符串,我们需要将其转换成对应的类型uint16_t port = stoi(argv[1]);//将字符串转换成端口号unique_ptr<TcpServer> usvr (new TcpServer(port));usvr->InitServer();usvr->StartServer();return 0;
}
创建
client.hpp
客户端头文件
#pragma once#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"namespace nt_client
{enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR};class TcpClient{public:TcpClient(const std::string& ip, const uint16_t port):server_ip_(ip), server_port_(port){}~TcpClient(){}// 初始化客户端void InitClient(){}// 启动客户端void StartClient(){}private:int sock_; // 套接字std::string server_ip_; // 服务器IPuint16_t server_port_; // 服务器端口号};
}
创建
client.cc
客户端源文件
#include <memory>
#include "client.hpp"using namespace std;
using namespace nt_client;void Usage(const char *program)
{cout << "Usage:" << endl;cout << "\t" << program << " ServerIP ServerPort" << endl;
}int main(int argc, char *argv[])
{if (argc != 3){// 错误的启动方式,提示错误信息Usage(argv[0]);return USAGE_ERR;}// 服务器IP与端口号string ip(argv[1]);uint16_t port = stoi(argv[2]);unique_ptr<TcpClient> usvr(new TcpClient(ip, port));usvr->InitClient();usvr->StartClient();return 0;
}
同时需要一个 Makefile
文件,用于快速编译和清理可执行程序
创建
Makefile
文件
.PHONY:all
all:server clientserver:server.ccg++ -o $@ $^ -std=c++11client:client.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -rf server client
1.2.服务端设计
1.2.1.初始化服务端——socket和bind函数
基于 TCP 协议实现的网络程序也需要 创建套接字、绑定 IP 和端口号,
注意:在使用 socket 函数创建套接字时,UDP 协议需要指定参数2为 SOCK_DGRAM,TCP 协议则是指定参数2为 SOCK_STREAM
server.hpp的初始化部分
// 初始化服务器void InitServer(){// 1.创建套接字sock_ = socket(AF_INET, SOCK_STREAM, 0);if (sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Socket Success! " << sock_ << std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(&local, 0, sizeof(local)); // 清零local.sin_family = AF_INET; // 网络local.sin_addr.s_addr = inet_addr(default_ip.c_str()); // 我设置为默认绑定任意可用IP地址local.sin_port = htons(port_); // 我设置为默认是8877if(bind(sock_,(const sockaddr *)&local, sizeof(local))<0){std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;exit(BIND_ERR);}// 3.TODO}
注意: 在绑定端口号时,一定需要把主机序列转换为网络序列
- 为什么在绑定端口号阶段需要手动转换为网络序列,而在发送信息阶段则不需要?
这是因为在发送信息阶段,recvfrom / sendto 等函数会自动将需要发送的信息转换为网络序列,接收信息时同样会将其转换为主机序列,所以不需要手动转换
这个代码和UDP版本的差不多!!!
如果使用的 UDP
协议,那么初始化服务器到此就结束了,但我们本文中使用的是 TCP
协议,这是一个 面向连接 的传输层协议,意味着在初始化服务器时,需要设置服务器为 监听 状态
TCP是面向连接的,服务器一般是比较被动的,没人访问,这个服务器只能干等着,而且也不能退出。就像你是一家餐馆的老板,你只能在餐馆里被动的等待顾客的到来,顾客什么时候来你也不知道。服务器要一直要处于等待链接到来的状态——也就是监听状态。
1.2.2.监听状态——listen函数
对于服务器,它是被动连接的。举一个生活中的例子,通常的情况下,移动的客服(相当于服务器)是等待着客户(相当于客户端)电话的到来。而这个过程,需要调用listen()函数。
#include<sys/socket.h>
int listen(int sockfd, int backlog);
listen() 函数的主要作用就是将套接字( sockfd )变成被动的连接监听套接字(被动等待客户端的连接)。所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。
我们看看参数
int sockfd:
- 服务端的socket,也就是socket函数创建的
- 标识绑定的,未连接的套接字的描述符。
int backlog:
- backlog 为请求队列的最大长度。
比如有100个用户链接请求,但是系统一次只能处理20个,那么剩下的80个不能不理人家,所以系统就创建个队列记录这些暂时不能处理,一会儿处理的连接请求,依先后顺序处理,那这个队列到底多大?就是这个参数设置,比如2,那么就允许两个新链接排队。这个不能无限大,那内存就不够了。
当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。
缓冲区的长度(能存放多少个客户端请求)可以通过 listen() 函数的 backlog 参数来指定,但究竟为多少并没有什么标准,可以根据你的需求来自定义设定,并发量小的话可以是10或者20。
如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。
当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。
这里需要注意的是,listen()函数不会阻塞,它主要做的事情为,将该套接字和套接字对应的连接队列长度告诉 Linux 内核,然后,listen()函数就结束。
注意:listen() 只是让套接字处于监听状态,并没有接收请求。接收请求需要使用 accept() 函数。
server.hpp的初始化服务器部分
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<cstring>namespace nt_server
{const uint16_t default_port = 8877; // 默认端口号const std::string default_ip = "0.0.0.0"; // 默认IPconst int backlog=5;//请求队列的最大长度enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR};class TcpServer{public:TcpServer( const uint16_t port = default_port,const std::string ip = default_ip): ip_(ip), port_(port){}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建套接字sock_ = socket(AF_INET, SOCK_STREAM, 0);if (sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Socket Success! " << sock_ << std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(&local, 0, sizeof(local)); // 清零local.sin_family = AF_INET; // 网络local.sin_addr.s_addr = inet_addr(default_ip.c_str()); // 我设置为默认绑定任意可用IP地址local.sin_port = htons(port_); // 我设置为默认是8877if(bind(sock_,(const sockaddr *)&local, sizeof(local))<0){std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;exit(BIND_ERR);}if(listen(sock_,backlog)<0){perror("Listen fail");exit(LIS_ERR);}}// 启动服务器void StartServer(){}private:int sock_; // 套接字(存疑)uint16_t port_; // 端口号std::string ip_; // ip地址};
}
至此我们的服务器初始化部分已经完成了 ,接下来我们进入服务器运行函数的设计。
// 启动服务器void StartServer(){for(;;){std::cout<<"TCP SERVER is running....."<<std::endl;sleep(1);}}
这个时候,我们就可以运行起来测试一下我们的服务器
查看监听的TCP端口
netstat -ntlp
很好完美通过测试!!
1.2.3.接受连接——accept函数
首先我要说的是,listen() 只是让套接字进入监听状态,并没有真正接收客户端请求,listen() 后面的代码会继续执行,直到遇到 accept()。accept() 会阻塞程序执行(后面代码不能被执行),直到有新的请求到来。
当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求。它的原型为:
sockfd 为服务器端套接字,addr 为 sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得。其中 addr
与 addrlen
是一个 输入输出型 参数,类似于 recvfrom
中的参数
accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sockfd 是服务器端的套接字,大家注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。
- 基于TCP连接的服务器端为什么需要用两个套接字?
在服务器端,socket()返回的套接字用于监听(listen)和接受(accept)客户端的连接请求。这个套接字不能用于与客户端之间发送和接收数据。
accept()接受一个客户端的连接请求,并返回一个新的套接字。所谓“新的”就是说这个套接字与socket()返回的用于监听和接受客户端的连接请求的套接字不是同一个套接字。与本次接受的客户端的通信是通过在这个新的套接字上发送和接收数据来完成的。
再次调用accept()可以接受下一个客户端的连接请求,并再次返回一个新的套接字(与socket()返回的套接字、之前accept()返回的套接字都不同的新的套接字)。这个新的套接字用于与这次接受的客户端之间的通信。
假设一共有3个客户端连接到服务器端。那么在服务器端就一共有4个套接字:第1个是socket()返回的、用于监听的套接字;其余3个是分别调用3次accept()返回的不同的套接字。
如果已经有客户端连接到服务器端,不再需要监听和接受更多的客户端连接的时候,可以关闭由socket()返回的套接字,而不会影响与客户端之间的通信。
当某个客户端断开连接、或者是与某个客户端的通信完成之后,服务器端需要关闭用于与该客户端通信的套接字。
这样子的话,我们网络通信使用的都是accept函数返回的套接字,那么服务端的socket套接字就只有监听作用 ,我们得把它们区分出来,一个就叫listen_sock_,另外一个叫accept_socket
server.hpp的StartServer的内容
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<cstring>
#include<unistd.h>namespace nt_server
{const uint16_t default_port = 8877; // 默认端口号const std::string default_ip = "0.0.0.0"; // 默认IPconst int backlog=5;//请求队列的最大长度enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR};class TcpServer{public:TcpServer( const uint16_t port = default_port,const std::string ip = default_ip): ip_(ip), port_(port){}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建套接字listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Socket Success! " << listen_sock_ << std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(&local, 0, sizeof(local)); // 清零local.sin_family = AF_INET; // 网络local.sin_addr.s_addr = inet_addr(default_ip.c_str()); // 我设置为默认绑定任意可用IP地址local.sin_port = htons(port_); // 我设置为默认是8877if(bind(listen_sock_,(const sockaddr *)&local, sizeof(local))<0){std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;exit(BIND_ERR);}if(listen(listen_sock_,backlog)<0){perror("Listen fail");exit(LIS_ERR);}}// 启动服务器void StartServer(){for(;;){//1.获取新连接struct sockaddr_in client;socklen_t len=sizeof(client);int accept_socket=accept(listen_sock_,(struct sockaddr*)&client,&len);if(accept_socket<0){std::cout<<"accept failed"<<std::endl;continue;}std::cout<<"get a new link...,sockfd:"<<accept_socket<<std::endl;//2.根据新连接来进行通信}}private:int listen_sock_; // socket套接字uint16_t port_; // 端口号std::string ip_; // ip地址};
}
这个时候我们就可以来测试一下我们的连接了,可是我们没有写客户端怎么办?
1.2.3.1.测试工具——telnet
Linux其实有这么一个工具,可以干这个测试的工作
- telnet
telnet是一种用于远程登录的网络协议,可以将本地计算机链接到远程主机。
Linux提供了telnet命令,它允许用户在本地计算机上通过telnet协议连接到远程主机,并执行各种操作。
使用telnet命令可以建立客户端与服务器之间的虚拟终端连接,这使得用户可以通过网络远程登录到其他计算机,并在远程计算机上执行命令,就像直接在本地计算机上操作一样。
要使用telnet命令,首先需要确保你的Linux系统已经安装了telnet软件包。如果未安装,可以通过以下命令在终端中安装:
sudo yum install telnet
安装完成后,你可以通过以下命令来使用telnet命令:
telnet [选项] [主机名或IP地址] [端口号]
可以使用不同的选项和参数来执行不同的操作,下面是一些常用的选项和参数:
- -l 用户名:指定用户名进行登录。
- -p 端口号:指定要连接的远程主机端口号。
- -E:在telnet会话中打开字符转义模式。
- -e 字符:指定telnet会话中的转义字符。
- -r:在执行用户登录之前不要求用户名。
- -K:在连接到telnet会话时要求密码。
好了,我们现在就来使用
我们点击ctrl+]就能进入连接
我们这个时候将服务端退出,却发现我们的连接还在
我们再次启动服务端,就出现了下面这些提示
这些东西刚好就验证了上面所说的东西。
如果已经有客户端连接到服务器端,不再需要监听和接受更多的客户端连接的时候,可以关闭由socket()返回的套接字,而不会影响与客户端之间的通信。
我们这个时候如果想终止我们客户端的连接,只需要在客户端输入q即可
1.2.4.网络通信——read和write函数
目前我们的网络连接已经完成了。接下来是通信的时刻。
我们在上面说过,accept()接受一个客户端的连接请求,并返回一个新的套接字。所谓“新的”就是说这个套接字与socket()返回的用于监听和接受客户端的连接请求的套接字不是同一个套接字。与本次接受的客户端的通信是通过在这个新的套接字上发送和接收数据来完成的。
Linux 不区分套接字文件和普通文件,使用 write() 可以向套接字中写入数据,使用 read() 可以从套接字中读取数据。
两台计算机之间的通信相当于两个套接字之间的通信,在服务器端用 write() 向套接字写入数据,客户端就能收到,然后再使用 read() 从套接字中读取出来,就完成了一次通信。
好了,话不多说,我们将这个通信模块写出了
// 业务处理
void Service(int sock, const std::string& clientip, const uint16_t& clientport)
{char buff[1024];std::string who = clientip + "-" + std::to_string(clientport);while (true){ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置if (n > 0){// 读取成功buff[n] = '\0';std::cout << "Server get: " << buff << " from " << who << std::endl;std::string respond = func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n == 0){// 表示当前读取到文件末尾了,结束读取std::cout << "Client " << who << " " << sock << " quit!" << std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题(暂时)std::cerr << "Read Fail!" << strerror(errno) << std::endl;close(sock); // 关闭文件描述符break;}}
}
根据UDP的经验,我们很快就能写出下面这个
server.hpp
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<cstring>
#include<unistd.h>
#include<functional>namespace nt_server
{const uint16_t default_port = 8877; // 默认端口号const std::string default_ip = "0.0.0.0"; // 默认IPconst int backlog=5;//请求队列的最大长度using func_t = std::function<std::string(std::string)>; // 回调函数类型enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR};class TcpServer{public:TcpServer(const func_t& func,const uint16_t port = default_port,const std::string ip = default_ip): ip_(ip), port_(port),func_(func)//注意这里要传1个业务处理函数{}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建套接字listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Socket Success! " << listen_sock_ << std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(&local, 0, sizeof(local)); // 清零local.sin_family = AF_INET; // 网络local.sin_addr.s_addr = inet_addr(default_ip.c_str()); // 我设置为默认绑定任意可用IP地址local.sin_port = htons(port_); // 我设置为默认是8877if(bind(listen_sock_,(const sockaddr *)&local, sizeof(local))<0){std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;exit(BIND_ERR);}if(listen(listen_sock_,backlog)<0){perror("Listen fail");exit(LIS_ERR);}}// 启动服务器void StartServer(){for(;;){//1.获取新连接struct sockaddr_in client;socklen_t len=sizeof(client);int accept_socket=accept(listen_sock_,(struct sockaddr*)&client,&len);if(accept_socket<0){std::cout<<"accept failed"<<std::endl;continue;}//2.业务处理//2.1客户端信息存储uint16_t clientport=ntohs(client.sin_port);//客户端端口号char iptr[32];inet_ntop(AF_INET,&(client.sin_addr),iptr,sizeof(iptr));//客户端IPstd::cout << "Server accept " << iptr << " - " << clientport << " " << accept_socket << " from " << listen_sock_ << " success!" << std::endl;//2.2.业务处理Service(accept_socket,iptr,clientport); }}// 通信服务+业务处理void Service(int sock, const std::string &clientip, const uint16_t &clientport){char buff[1024];std::string who = clientip + "-" + std::to_string(clientport);while (true){ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置if (n > 0){// 读取成功buff[n] = '\0';std::cout << "Server get: " << buff << " from " << who << std::endl;std::string respond = func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n == 0){// 表示当前读取到文件末尾了,结束读取std::cout << "Client " << who << " " << sock << " quit!" << std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题(暂时)std::cerr << "Read Fail!" << strerror(errno) << std::endl;close(sock); // 关闭文件描述符break;}}}private:int listen_sock_; // socket套接字uint16_t port_; // 端口号std::string ip_; // ip地址func_t func_;//业务处理函数};
}
1.2.5.服务器源文件设计
对于当前的 TCP
网络程序(字符串回响)来说,业务处理函数逻辑非常简单,无非就是直接将客户端发送过来的消息,重新转发给客户端
server.cc
#include <string>
#include <vector>
#include <memory> // 智能指针相关头文件
#include <cstdio>
#include "server.hpp"using namespace std;
using namespace nt_server;// 业务处理回调函数(字符串回响)
string echo(string request)
{return request;
}void Usage(const char* program)
{cout << "Usage:" << endl;cout << "\t" << program << " ServerPort" << endl;
}int main(int argc, char* argv[])
{if (argc != 2){// 错误的启动方式,提示错误信息Usage(argv[0]);return USAGE_ERR;}//命令行参数都是字符串,我们需要将其转换成对应的类型uint16_t port = stoi(argv[1]);//将字符串转换成端口号unique_ptr<TcpServer> usvr (new TcpServer(echo,port));usvr->InitServer();usvr->StartServer();return 0;
}
好了,我们来测试
很完美啊,服务器收到了我们发的,对方也能收到
可是这个不是我们写的客户端,我们得自己去实现一下自己的客户端
1.3.客户端设计
话不多说,先看原理图
可以看到啊这个客户端的实现可比服务端简单的多。
client.cc
#include <iostream>
#include <memory>
#include "client.hpp"using namespace std;
using namespace nt_client;void Usage(const char* program)
{cout << "Usage:" << endl;cout << "\t" << program << " ServerIP ServerPort" << endl;
}int main(int argc, char* argv[])
{if (argc != 3){// 错误的启动方式,提示错误信息Usage(argv[0]);return USAGE_ERR;}//命令行参数都是字符串,我们需要将其转换成对应的类型std::string ip=argv[1];uint16_t port = stoi(argv[2]);//将字符串转换成端口号unique_ptr<TcpClient> usvr (new TcpClient(ip,port));usvr->InitClient();usvr->StartClient();return 0;
}
1.3.1.初始化客户端——socket函数
这个和UDP版本的其实很像。
对于客户端来说,服务器的 IP 地址与端口号是两个不可或缺的元素,因此在客户端类中,server_ip 和 server_port 这两个成员是少不了的,当然得有 socket 套接字
初始化客户端只需要干一件事:创建套接字,客户端是主动发起连接请求的一方,也就意味着它不需要使用 listen 函数设置为监听状态
此外呢,和UDP版的程序一样,TCP版本的也是不需要我们手动写代码去bind的,也是操作系统自己去自动bind的
client.hpp
客户端头文件
#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>namespace nt_client
{enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR};class TcpClient{public:TcpClient(const std::string& ip, const uint16_t port):server_ip_(ip), server_port_(port){}~TcpClient(){}// 初始化客户端void InitClient(){// 创建套接字sock_ = socket(AF_INET, SOCK_STREAM, 0);if (sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Sock Succeess! " << sock_ << std::endl;}// 启动客户端void StartClient(){}private:int sock_; // 套接字std::string server_ip_; // 服务器IPuint16_t server_port_; // 服务器端口号};
}
1.3.2.连接服务器——connect函数
因为 TCP 协议是面向连接的,服务器已经处于处理连接请求的状态了,客户端现在需要做的就是尝试进行连接,使用 connect 函数进行连接
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
第一个参数:int sockdf:
socket文件描述符
第二个参数: const struct sockaddr *addr:
传入参数,指定服务器端地址信息,含IP地址和端口号
第三个参数:socklen_t addrlen:
传入参数,传入sizeof(addr)大小
返回值:
成功: 0
失败:-1,设置errno
当客户端调用 connect()函数之后,发生一下情况之一才会返回(完成函数调用)
- 服务器端接收连接请求
- 发生断网的异常情况而终端连接请求
需要注意的是,所谓的“接收连接”并不意味着服务器调用 accept()函数,。其实是服务器端把连接请求信息记录到listen的请求队列(因此 connect()函数返回后并不进行数据交换。而是要等服务器端 accept 之后才能进行数据交换(read、write)。
客户端端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。
这就是客户端的难点所在了。
注意:
在连接过程中,可能遇到很多问题,比如 网络传输失败、服务器未启动 等,这些问题的最终结果都是客户端连接失败,如果按照之前的逻辑(失败就退出),那么客户端的体验感会非常不好,因此在面对连接失败这种常见问题时,客户端应该尝试重连,如果重连数次后仍然失败,才考虑终止进程
StartClient()
启动客户端函数 — 位于client.hpp
中的TcpClient
类
// 启动客户端
void StartClient()
{// 填充服务器的 sockaddr_in 结构体信息struct sockaddr_in server;socklen_t len = sizeof(server);memset(&server, 0, len);server.sin_family = AF_INET;inet_aton(server_ip_.c_str(), &server.sin_addr); // 将点分十进制转化为二进制IP地址的另一种方法server.sin_port = htons(server_port_);// 尝试重连 5 次int n = 5;while(n){int ret = connect(sock_, (const struct sockaddr*)&server, len);if(ret == 0){// 连接成功,可以跳出循环break;}// 尝试进行重连std::cerr << "网络异常,正在进行重连... 剩余连接次数: " << --n << std::endl;sleep(1);}// 如果剩余重连次数为 0,证明连接失败if(n == 0){std::cerr << "连接失败! " << strerror(errno) << std::endl;close(sock_);exit(CONNECT_ERR);}// 连接成功std::cout << "连接成功!" << std::endl;// 进行业务处理// GetService();
}
当然相应的错误码也得添加,我们在下面的源代码里再告诉大家
我们现在可以来试运行一下,现在先不启动服务器,编译并启动客户端,模拟连接失败的情况
如果在数秒之后启动再服务器,可以看到重连成功
1.3.3.获取服务
客户端在进行获取服务的时候,同样可以使用 read 和 write 进行网络通信
Service() 获取服务函数 — 位于 client.hpp 客户端头文件中的 TcpClient 类
// 获取服务
void GetService()
{char buff[1024];std::string who = server_ip_ + "-" + std::to_string(server_port_);while(true){// 由用户输入信息std::string msg;std::cout << "Please Enter >> ";std::getline(std::cin, msg);// 发送信息给服务器write(sock_, msg.c_str(), msg.size());// 接收来自服务器的信息ssize_t n = read(sock_, buff, sizeof(buff) - 1);if(n > 0){// 正常通信buff[n] = '\0';std::cout << "Client get: " << buff << " from " << who << std::endl;}else if(n == 0){// 读取到文件末尾(服务器关闭了)std::cout << "Server " << who << " quit!" << std::endl;close(sock_); // 关闭文件描述符break;}else{// 读取异常std::cerr << "Read Fail!" << strerror(errno) << std::endl;close(sock_); // 关闭文件描述符break;}}
}
1.3.4.客户端源代码
至此整个 基于 TCP
协议的字符串回响程序 就完成了
client.hpp
#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<unistd.h>
#include<sys/wait.h>namespace nt_client
{enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR,CONNECT_ERR};class TcpClient{public:TcpClient(const std::string& ip, const uint16_t port):server_ip_(ip), server_port_(port){}~TcpClient(){}// 初始化客户端void InitClient(){// 创建套接字sock_ = socket(AF_INET, SOCK_STREAM, 0);if (sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Sock Succeess! " << sock_ << std::endl;}// 启动客户端void StartClient(){// 填充服务器的 sockaddr_in 结构体信息struct sockaddr_in server;socklen_t len = sizeof(server);memset(&server, 0, len);//清空server.sin_family = AF_INET;//网络通信inet_aton(server_ip_.c_str(), &server.sin_addr); // 将点分十进制转化为二进制IP地址的另一种方法server.sin_port = htons(server_port_);// 尝试重连 5 次int n = 5;while (n){int ret = connect(sock_, (const struct sockaddr *)&server, len);if (ret == 0){// 连接成功,可以跳出循环break;}// 尝试进行重连std::cerr << "网络异常,正在进行重连... 剩余连接次数: " << --n << std::endl;sleep(1);}// 如果剩余重连次数为 0,证明连接失败if (n == 0){std::cerr << "连接失败! " << strerror(errno) << std::endl;close(sock_);exit(CONNECT_ERR);}// 连接成功std::cout << "连接成功!" << std::endl;// 获取服务GetService();}// 获取服务void GetService(){char buff[1024];std::string who = server_ip_ + "-" + std::to_string(server_port_);while (true){// 由用户输入信息std::string msg;std::cout << "Please Enter >> ";std::getline(std::cin, msg);// 发送信息给服务器write(sock_, msg.c_str(), msg.size());// 接收来自服务器的信息ssize_t n = read(sock_, buff, sizeof(buff) - 1);if (n > 0){// 正常通信buff[n] = '\0';std::cout << "Client get: " << buff << " from " << who << std::endl;}else if (n == 0){// 读取到文件末尾(服务器关闭了)std::cout << "Server " << who << " quit!" << std::endl;close(sock_); // 关闭文件描述符break;}else{// 读取异常std::cerr << "Read Fail!" << strerror(errno) << std::endl;close(sock_); // 关闭文件描述符break;}}}private:int sock_; // 套接字std::string server_ip_; // 服务器IPuint16_t server_port_; // 服务器端口号};
}
client.cc
#include <iostream>
#include <memory>
#include "client.hpp"using namespace std;
using namespace nt_client;void Usage(const char* program)
{cout << "Usage:" << endl;cout << "\t" << program << " ServerIP ServerPort" << endl;
}int main(int argc, char* argv[])
{if (argc != 3){// 错误的启动方式,提示错误信息Usage(argv[0]);return USAGE_ERR;}//命令行参数都是字符串,我们需要将其转换成对应的类型std::string ip=argv[1];uint16_t port = stoi(argv[2]);//将字符串转换成端口号unique_ptr<TcpClient> usvr (new TcpClient(ip,port));usvr->InitClient();usvr->StartClient();return 0;
}
1.4.测试
可以看到,当客户端向服务器发起连接请求时,服务器可以识别并接受连接,双方建立连接关系后,可以正常进行通信;当客户端主动退出(断开连接),服务器也能感知到,并判断出是谁断开了连接
当客户端已经和服务端成功连接上了,但是这个时候终止了服务端,我们此时立马重启服务器,会发现短期内无法再次启动服务(显示端口正在被占用),这是由于 TCP
协议断开连接时的特性导致的(正在处于 TIME_WAIT
状态),详细原因将会在后续博客中讲解
服务端关闭的时候,客户端还是存在连接的,但是发出消息之后,就会自动退出。
到目前为止我们的程序都是运行的挺好的,接下来我们再加入一个客户端看看
对于之前编写的 字符串回响程序 来说,如果只有一个客户端进行连接并通信,是没有问题的,但如果有多个客户端发起连接请求,并尝试进行通信,服务器是无法应对的
我们可以看看server.hpp的
// 启动服务器void StartServer(){for(;;){//1.获取新连接。。。。。。int accept_socket=accept(listen_sock_,(struct sockaddr*)&client,&len);。。。。。//2.2.业务处理Service(accept_socket,iptr,clientport); }}
原因在于 服务器是一个单进程版本,处理连接请求 和 业务处理 是按顺序执行的,如果想处理下一个连接请求,需要把当前的业务处理完成
为什么客户端B会显示当前已经连接成功?
这是因为是客户端是主动发起连接请求的一方,在请求发出后,如果出现连接错误,客户端就认为已经连接成功了,但实际上服务器还没有处理这个连接请求
A客户端断开连接,服务端才会去处理B客户端的连接,此时才轮到B客户端和服务器进行通信
这显然是服务器的问题,处理连接请求 与 提供服务 应该交给两个不同的执行流完成,可以使用多进程或者多线程解决。
1.5.多进程版服务器
1.5.1.父进程阻塞等待
所以当前需要实现的网络程序核心功能为:当服务器成功处理连接请求后,fork
新建一个子进程,用于进行业务处理,原来的进程专注于处理连接请求
子进程创建成功后,会继承父进程的文件描述符表,能轻而易举的获取客户端的 socket 套接字,从而进行网络通信
当然不止文件描述符表,得益于 写时拷贝 机制,子进程还会共享父进程的变量,当发生修改行为时,才会自己创建
注意: 当子进程取走客户端的 socket 套接字进行通信后,父进程需要将其关闭(因为它不需要了),避免文件描述符泄漏
// 进程创建、等待所需要的头文件
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>// 启动服务器void StartServer(){for (;;){// 1.获取新连接struct sockaddr_in client;socklen_t len = sizeof(client);int accept_socket = accept(listen_sock_, (struct sockaddr *)&client, &len);if (accept_socket < 0){std::cout << "accept failed" << std::endl;continue;}// 2.业务处理// 2.1客户端信息存储uint16_t clientport = ntohs(client.sin_port); // 客户端端口号char clientip[32];inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); // 客户端IPstd::cout << "Server accept " << clientip << " - " << clientport << " " << accept_socket << " from " << listen_sock_ << " success!" << std::endl;// 3.创建子进程pid_t id = fork();if (id < 0){// 创建子进程失败,暂时不与当前客户端建立通信会话close(accept_socket);std::cerr << "Fork Fail!" << std::endl;}else if (id == 0){// 子进程内close(listen_sock_); // 子进程不需要监听(建议关闭)// 执行业务处理函数Service(accept_socket, clientip, clientport);exit(0); // 子进程退出}else//父进程{// 父进程需要等待子进程pid_t ret = waitpid(id, nullptr, 0); // 默认为阻塞式等待if (ret == id)std::cout << "Wait " << id << " success!";}}}
虽然此时成功创建了子进程,但父进程(处理连接请求)仍然需要等待子进程退出后,才能继续运行,说白了就是 父进程现在处于阻塞等待状态,还是会出现上面那种问题
还是出现了上面那个问题
因此父进程应该需要设置为 非阻塞等待
1.5.2.非阻塞等待版本
要是想父进程不阻塞等待,其实有很多种方法
- 方式一:通过参数设置为非阻塞等待(不推荐)
- 方式二:设置
SIGCHLD
信号的处理动作为子进程回收(不是很推荐) - 方式三:忽略
SIGCHLD
信号(推荐使用) - 方式四:设置孙子进程(不是很推荐)
我们接下来来一一学习。
- 方式一:通过参数设置为非阻塞等待(不推荐)
这个完成起来就很简单了,可以直接给 waitpid()
函数的参数3传递 WNOHANG
,表示当前为 非阻塞等待
pid_t ret = waitpid(id, nullptr, WNOHANG); // 设置为非阻塞式等待
也是可以正常通信
这里显示有3个进程
这种方法可行,但不推荐,原因如下:虽然设置成了非阻塞式等待,但父进程终究是需要通过 waitpid()
函数来尝试等待子进程,倘若父进程一直卡在 accept()
函数处,会导致子进程退出后暂时无人收尸,进而导致资源泄漏
- 方式二:设置
SIGCHLD
信号的处理动作为子进程回收(不是很推荐)
SIGCHLD信号的含义:
简单的说,子进程退出时,父进程会收到一个SIGCHLD信号,默认的处理动作是忽略这个信号,也就是什么都不做。
我们知道我们可以使用signal函数来修改SIGCHLD信号的处理动作,而常规的做法是在这个信号处理函数中调用wait函数获取子进程的退出状态。
我们知道,unix中信号是采用异步处理某事的机制,好比说你准备去做某事,去之前跟邻居张三说如果李四来找你的话就通知他一声,这让你可以抽身出来去做这件事,而李四真正来访时会有人通知你,这个就是异步信号一个较为形象的比喻。
一般的,父进程在生成子进程之后会有两种情况:
- 一是父进程继续去做别的事情,类似上面举的例子;
- 另一是父进程啥都不做,一直在wait子进程退出。
SIGCHLD信号就是为这第一种情况准备的,它让父进程去做别的事情,而只要父进程注册了处理该信号的函数,在子进程退出时就会调用该函数,在函数中wait子进程得到终止状态之后再继续做父进程的事情。
设置 SIGCHLD
信号的处理动作为 回收子进程后,父进程就不必再考虑回收子进程的问题
注意: 因为现在处于
TcpServer
类中,handler()
函数需要设置为静态(避免隐含的this
指针),避免不符合signal()
函数中信号处理函数的参数要求
#include <signal.h> // 信号处理相关头文件// 需要设置为静态
static void handler(int signo)
{printf("进程 %d 捕捉到了 %d 号信号\n", getpid(), signo);// 这里的 1 表示父进程等待时,只要是已经退出了的子进程,都可以进行回收while (1){pid_t ret = waitpid(-1, NULL, WNOHANG);if (ret > 0)printf("父进程: %d 已经成功回收了 %d 号进程\n", getpid(), ret);elsebreak;}printf("子进程回收成功\n");
}// 启动服务器
void StartServer()
{// 设置 SIGCHLD 信号的处理动作signal(SIGCHLD, handler);while (!quit_){// ...// 3.创建子进程pid_t id = fork();if(id < 0){// 创建子进程失败,暂时不与当前客户端建立通信会话close(accept_socket);std::cerr << "Fork Fail!" << std::endl;}else if(id == 0){// 子进程内close(listen_sock_); // 子进程不需要监听(建议关闭)// 执行业务处理函数Service(accept_socket, clientip, clientport);exit(0); // 子进程退出}}
}
我们来测试一下
为什么不是很推荐这种方法?因为这种方法实现起来比较麻烦,不如直接忽略 SIGCHLD
信号
- 方式三:忽略
SIGCHLD
信号(推荐使用)
子进程在终止时会给父进程发生SIGCHLD信号(17号信号),该信号的默认处理动作是什么都不做,这个时候父进程必须检测并回收子进程。
我们上面说过使用signal可以修改对信号的默认处理动作,那么我们可以把这个默认处理动作修改成直接忽略该信号,
就是下面这个
// 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN);
这个SIG_IGN是函数指针,表示忽略信号。 上面这个语句将SIGCHLD信号(17号信号)的处理方式设为忽略,这里的忽略是个特例,只是父进程不对其进行处理,转而由 init进程(爷爷进程) 对其负责,自动清理资源并进行回收,不会产生 僵尸进程,省去了大量僵尸进程占用系统资源。(Linux Only)
直接在
StartServer()
服务器启动函数刚开始时,使用signal()
函数设置SIGCHLD
信号的执行动作为 忽略
忽略了该信号后,就不需要父进程等待子进程退出了(由操作系统承担)
#include <signal.h> // 信号处理相关头文件// 启动服务器
void StartServer()
{// 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN);while (!quit_){// ...// 3.创建子进程pid_t id = fork();if(id < 0){// 创建子进程失败,暂时不与当前客户端建立通信会话close(sock);std::cerr << "Fork Fail!" << std::endl;}else if(id == 0){// 子进程内close(listensock_); // 子进程不需要监听(建议关闭)// 执行业务处理函数Service(sock, clientip, clientport);exit(0); // 子进程退出}}
}
我们连接
我们断开连接
我们发现子进程没有变僵尸进程,很完美。
- 方式四:设置孙子进程(不是很推荐)
众所周知,父进程只需要对子进程负责,至于孙子进程交给子进程负责,如果某个子进程的父进程终止运行了,那么它就会变成 孤儿进程,父进程会变成 1 号进程,也就是由操作系统领养,回收进程的重担也交给了操作系统
可以利用该特性,在子进程内部再创建一个子进程(孙子进程),然后子进程退出,父进程可以直接回收(不必阻塞),子进程(孙子进程)的父进程变成 1 号进程
这种实现方法比较巧妙,而且与我们后面即将学到的 守护进程 有关
注意: 使用这种方式时,父进程是需要等待子进程退出的
// 启动服务器
void StartServer()
{while (!quit_){// ...// 3.创建子进程pid_t id = fork();if(id < 0){// 创建子进程失败,暂时不与当前客户端建立通信会话close(sock);std::cerr << "Fork Fail!" << std::endl;}else if(id == 0){// 子进程内close(listensock_); // 子进程不需要监听(建议关闭)// 再创建孙子进程if(fork() > 0)exit(0); // 孙子进程创建成功的话,子进程退出,由父进程接受孙子进程// 执行业务处理函数Service(sock, clientip, clientport);exit(0); // 子进程退出}else{// 父进程需要等待子进程pid_t ret = waitpid(id, nullptr, 0);if(ret == id)std::cout << "Wait " << id << " success!";}}
}
这种方法代码也很简单,但依旧不推荐,因为倘若连接请求变多,会导致孤儿进程变多,孤儿进程由操作系统接管,数量变多会给操作系统带来负担
以上就是设置 非阻塞 的四种方式,推荐使用方式二:忽略 SIGCHLD
信号
至此我们的 字符串回响程序 可以支持多客户端了
1.5.3.服务端源代码
这里补充一个点:当子进程取走 sock
套接字进行网络通信后,父进程就不需要使用 sock
套接字了,可以将其进行关闭,下次连接时继续使用,避免文件描述符不断增长
// 启动服务器
void StartServer()
{// 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN);while (!quit_){// 1.处理连接请求// ...// 2.如果连接失败,继续尝试连接// ...// 连接成功,获取客户端信息// ...// 3.创建子进程// ...close(accept_socket); // 父进程不再需要资源(建议关闭)}
}
这个补丁可以减少资源消耗,建议加上,前面是忘记加了,并且不太好修改,server.hpp
服务器头文件完整代码如下
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h> // 信号处理相关头文件namespace nt_server
{const uint16_t default_port = 8877; // 默认端口号const std::string default_ip = "0.0.0.0"; // 默认IPconst int backlog = 5; // 请求队列的最大长度using func_t = std::function<std::string(std::string)>; // 回调函数类型enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR};class TcpServer{public:TcpServer(const func_t &func, const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port), func_(func) // 注意这里要传1个业务处理函数{}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建套接字listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Socket Success! " << listen_sock_ << std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(&local, 0, sizeof(local)); // 清零local.sin_family = AF_INET; // 网络local.sin_addr.s_addr = inet_addr(default_ip.c_str()); // 我设置为默认绑定任意可用IP地址local.sin_port = htons(port_); // 我设置为默认是8877if (bind(listen_sock_, (const sockaddr *)&local, sizeof(local)) < 0){std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;exit(BIND_ERR);}if (listen(listen_sock_, backlog) < 0){perror("Listen fail");exit(LIS_ERR);}}// 启动服务器void StartServer(){// 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN);for (;;){// 1.获取新连接struct sockaddr_in client;socklen_t len = sizeof(client);int accept_socket = accept(listen_sock_, (struct sockaddr *)&client, &len);if (accept_socket < 0){std::cout << "accept failed" << std::endl;continue;}// 2.业务处理// 2.1客户端信息存储uint16_t clientport = ntohs(client.sin_port); // 客户端端口号char clientip[32];inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); // 客户端IPstd::cout << "Server accept " << clientip << " - " << clientport << " " << accept_socket << " from " << listen_sock_ << " success!" << std::endl;// 3.创建子进程pid_t id = fork();if (id < 0){// 创建子进程失败,暂时不与当前客户端建立通信会话close(accept_socket);std::cerr << "Fork Fail!" << std::endl;}else if (id == 0){// 子进程内close(listen_sock_); // 子进程不需要监听(建议关闭)// 执行业务处理函数Service(accept_socket, clientip, clientport);exit(0); // 子进程退出}close(accept_socket); // 父进程不再需要资源(建议关闭)}}// 通信服务+业务处理void Service(int sock, const std::string &clientip, const uint16_t &clientport){char buff[1024];std::string who = clientip + "-" + std::to_string(clientport);while (true){ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置if (n > 0){// 读取成功buff[n] = '\0';std::cout << "Server get: " << buff << " from " << who << std::endl;std::string respond = func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n == 0){// 表示当前读取到文件末尾了,结束读取std::cout << "Client " << who << " " << sock << " quit!" << std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题(暂时)std::cerr << "Read Fail!" << strerror(errno) << std::endl;close(sock); // 关闭文件描述符break;}}}private:int listen_sock_; // socket套接字uint16_t port_; // 端口号std::string ip_; // ip地址func_t func_; // 业务处理函数};
}
1.6.多线程版本服务器
从内核的观点看,
- 进程的目的就是担当分配系统资源(CPU时间、内存等)的基本单位。
- 线程是进程的一个执行流,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,
而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。
据统计,总的说来,一个进程的开销大约是一个线程开销的30倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。
由于我们的多进程消耗的资源太大了,我们可以使用多线程版本
1.6.1.使用原生线程库
我们使用我们的线程处理函数
由于我们创建线程是用来提供服务的,而服务端的业务中有一个Service(),它需要我们的线程去传入 Service()
函数中的所有参数,同时也需要具备调用 Service()
业务处理函数的能力,我们只能把Service()
函数中的所有参数和this指针传进去,而这单凭一个 void*
的参数是无法解决的,为此我们可以创建一个类,里面可以包含我们所需要的参数——Service()
函数中的所有参数和this指针
// 包含我们所需参数的类型
class ThreadData
{
public:ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr):sock_(sock), clientip_(ip), clientport_(port), current_(ptr){}// 设置为公有是为了方便访问
public:int sock_;std::string clientip_;uint16_t clientport_;TcpServer* current_; // 指向 TcpServer 对象的指针
};
接下来就可以考虑如何借助多线程了
- 线程创建后,需要关闭不必要的 socket 套接字吗?
不需要,线程之间是可以共享这些资源的,无需关闭
- 如何设置主线程不必等待次线程退出?
可以把次线程进行分离
所以接下来我们需要在连接成功后,创建次线程,利用已有信息构建 ThreadData 对象,为次线程编写回调函数(最终目的是为了执行 Service() 函数)
注意: 因为当前在类中,线程的回调函数需要使用 static 设置为静态函数
server.hpp
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h> // 信号处理相关头文件
#include<pthread.h>namespace nt_server
{const uint16_t default_port = 8877; // 默认端口号const std::string default_ip = "0.0.0.0"; // 默认IPconst int backlog = 5; // 请求队列的最大长度enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR};class TcpServer; // 前置声明// 包含我们所需参数的类型class ThreadData{public:ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr):sock_(sock), clientip_(ip), clientport_(port), current_(ptr){}// 设置为公有是为了方便访问public:int sock_;std::string clientip_;uint16_t clientport_;TcpServer* current_; // 指向 TcpServer 对象的指针};using func_t = std::function<std::string(std::string)>; // 回调函数类型class TcpServer{public:TcpServer(const func_t &func, const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port), func_(func) // 注意这里要传1个业务处理函数{}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建套接字listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Socket Success! " << listen_sock_ << std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(&local, 0, sizeof(local)); // 清零local.sin_family = AF_INET; // 网络local.sin_addr.s_addr = inet_addr(default_ip.c_str()); // 我设置为默认绑定任意可用IP地址local.sin_port = htons(port_); // 我设置为默认是8877if (bind(listen_sock_, (const sockaddr *)&local, sizeof(local)) < 0){std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;exit(BIND_ERR);}if (listen(listen_sock_, backlog) < 0){perror("Listen fail");exit(LIS_ERR);}}// 启动服务器void StartServer(){// 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN);for (;;){// 1.获取新连接struct sockaddr_in client;socklen_t len = sizeof(client);int accept_socket = accept(listen_sock_, (struct sockaddr *)&client, &len);if (accept_socket < 0){std::cout << "accept failed" << std::endl;continue;}// 2.业务处理// 2.1客户端信息存储uint16_t clientport = ntohs(client.sin_port); // 客户端端口号char clientip[32];inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); // 客户端IPstd::cout << "Server accept " << clientip << " - " << clientport << " " << accept_socket << " from " << listen_sock_ << " success!" << std::endl;// 3.创建线程及所需要的线程信息类ThreadData* td = new ThreadData(accept_socket, clientip, clientport, this);pthread_t p;pthread_create(&p, nullptr, Routine, td);}}// 线程回调函数static void* Routine(void* args){// 线程分离pthread_detach(pthread_self());ThreadData* td = static_cast<ThreadData*>(args);// 调用业务处理函数td->current_->Service(td->sock_, td->clientip_, td->clientport_);// 销毁对象delete td;return (void*)0;}// 通信服务+业务处理void Service(int sock, const std::string &clientip, const uint16_t &clientport){char buff[1024];std::string who = clientip + "-" + std::to_string(clientport);while (true){ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置if (n > 0){// 读取成功buff[n] = '\0';std::cout << "Server get: " << buff << " from " << who << std::endl;std::string respond = func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n == 0){// 表示当前读取到文件末尾了,结束读取std::cout << "Client " << who << " " << sock << " quit!" << std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题(暂时)std::cerr << "Read Fail!" << strerror(errno) << std::endl;close(sock); // 关闭文件描述符break;}}}private:int listen_sock_; // socket套接字uint16_t port_; // 端口号std::string ip_; // ip地址func_t func_; // 业务处理函数};
}
因为当前使用了 原生线程库,所以在编译时,需要加上 -lpthread
makefile
.PHONY:all
all:server clientserver:server.ccg++ -o $@ $^ -std=c++11 -lpthreadclient:client.ccg++ -o $@ $^ -std=c++11 -lpthread.PHONY:clean
clean:rm -rf server client
接下来就是编译并运行程序,可以看到 当前只有一个进程,同时有两个线程在运行
1.6.1.线程池版本
上面那个版本其实有点问题,连接都准备好了,才创建线程,如果创建线程所需要的资源较多,会拖慢服务器整体连接效率
如果每来一个用户我们就得创建一个线程,那么当来了很多用户,就会消耗很多资源。
我们不想等到客户来了才创建我们的线程,我们可以提前创建好
我们不提供死循环服务
为此可以改用之前实现的 线程池,我们把那个线程池的代码拿过来
ThreadPool.hpp
#pragma once#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
#include <unistd.h>// 线程信息结构体
struct ThreadInfo
{pthread_t tid; // 线程IDstd::string name; // 线程名称
};// 默认线程数量
static const int defalutnum = 5;// 线程池模板类
template <class T>
class ThreadPool
{
private:// 互斥锁加锁函数void Lock(){pthread_mutex_lock(&mutex_);}// 互斥锁解锁函数void Unlock(){pthread_mutex_unlock(&mutex_);}// 唤醒等待的线程void Wakeup(){pthread_cond_signal(&cond_);}// 线程休眠等待条件变量void ThreadSleep(){pthread_cond_wait(&cond_, &mutex_);}// 判断任务队列是否为空bool IsQueueEmpty(){return tasks_.empty();}// 根据线程ID获取线程名称std::string GetThreadName(pthread_t tid){for (const auto &ti : threads_){if (ti.tid == tid)return ti.name;}return "None";}public:// 线程处理任务的函数static void *HandlerTask(void *args){ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);std::string name = tp->GetThreadName(pthread_self());while (true){tp->Lock();while (tp->IsQueueEmpty()){tp->ThreadSleep();}T t = tp->Pop();tp->Unlock();t.Run();}}// 启动线程池中的所有线程void Start(){int num = threads_.size();for (int i = 0; i < num; i++){threads_[i].name = "thread-" + std::to_string(i + 1);pthread_create(&(threads_[i].tid), nullptr, HandlerTask, this);}}// 从任务队列中取出一个任务T Pop(){T t = tasks_.front();tasks_.pop();return t;}// 向任务队列中添加一个任务void Push(const T &t){Lock();tasks_.push(t);Wakeup();Unlock();}// 获取线程池单例对象static ThreadPool<T> *GetInstance(){if (nullptr == tp_) // 如果线程池对象不存在,则创建一个新的线程池对象{pthread_mutex_lock(&lock_); // 加锁保证线程安全if (nullptr == tp_) // 再次检查是否已经创建了线程池对象,防止多线程环境下的竞争条件{std::cout << "log: singleton create done first!" << std::endl;tp_ = new ThreadPool<T>(); // 创建线程池对象}pthread_mutex_unlock(&lock_); // 解锁}return tp_; // 返回线程池对象指针}private:// 构造函数,初始化线程池,可以指定线程数量,默认为defalutnumThreadPool(int num = defalutnum) : threads_(num){pthread_mutex_init(&mutex_, nullptr); // 初始化互斥锁pthread_cond_init(&cond_, nullptr); // 初始化条件变量}// 析构函数,销毁线程池资源~ThreadPool(){pthread_mutex_destroy(&mutex_); // 销毁互斥锁pthread_cond_destroy(&cond_); // 销毁条件变量}// 禁止拷贝构造和赋值操作符,确保线程池对象的单一性ThreadPool(const ThreadPool<T> &) = delete;const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete; // a=b=c
private:// 线程信息列表std::vector<ThreadInfo> threads_;// 任务队列std::queue<T> tasks_;// 互斥锁和条件变量用于同步和通信pthread_mutex_t mutex_;pthread_cond_t cond_;// 线程池单例对象指针和互斥锁静态成员变量static ThreadPool<T> *tp_;static pthread_mutex_t lock_;
};// 初始化线程池单例对象指针和互斥锁静态成员变量
template <class T>
ThreadPool<T> *ThreadPool<T>::tp_ = nullptr;
template <class T>
pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER;
为了使用上面这个线程池,我们很快就能写出下面这些代码
// 启动服务器void StartServer(){.......//线程池的设计--》使用之前必须先启动ThreadPool<Task>::GetInstance()->Start();for (;;){......// 3.把任务交给线程池Task task1(accept_socket,clientip,clientport);ThreadPool<Task>::GetInstance()->Push(task1);}}
这里提一点,
- 我创建task1的时候为什么传这3个参数?
我创建task1是为了把任务交给线程池,但是我们的任务是什么?是这个Server函数,
大家可以回去看看server.hpp,看看Server函数的参数就明白了。
接下来就要设计我们的Task类了
Task.hpp
#pragma once#include <iostream>//任务类
class Task
{
public:Task(int sockfd, const std::string &clientip, const uint16_t &clientport):sockfd_(sockfd),clientip_(clientip),clientport_(clientport){}~Task(){}void Run()//因为我们的线程池要求Task类必须含有Run函数{ }private:int sockfd_;std::string clientip_;uint16_t clientport_;
};
这里提一下,我们创建了Task.hpp,那么原来server.hpp的Server函数和线程回调函数就可以删除了
Task.hpp完全体
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include<pthread.h>using func_t = std::function<std::string(std::string)>; // 回调函数类型// 任务类
class Task
{
public:Task(int sockfd, const std::string &clientip, const uint16_t &clientport,const func_t &func): sockfd_(sockfd), clientip_(clientip), clientport_(clientport), func_2(func) // 注意这里要传1个业务处理函数{}~Task(){}void Run(){char buff[1024];std::string who = clientip_ + "-" + std::to_string(clientport_);//while (true),如果线程池的线程一直循环在干这件事的话,效率会极其低下ssize_t n = read((sockfd_),buff, sizeof(buff)- 1); // 预留 '\0' 的位置if (n > 0){// 读取成功buff[n] = '\0';std::cout << "Server get: " << buff << " from " << who << std::endl;std::string respond = func_2(buff); // 实际业务处理由上层指定// 发送给服务器write(sockfd_, buff, strlen(buff));}else if (n == 0){// 表示当前读取到文件末尾了,结束读取std::cout << "Client " << who << " " << sockfd_ << " quit!" << std::endl;close(sockfd_); // 关闭文件描述符 }else{// 读取出问题(暂时)std::cerr << "Read Fail!" << strerror(errno) << std::endl;close(sockfd_); // 关闭文件描述符}}private:int sockfd_;//accept返回的套接字std::string clientip_;//用户IPuint16_t clientport_;//用户端口号func_t func_2; // 业务处理函数
};
大家注意到了没有,其实这个Run就是之前的Server函数,只不过有一些小改动,那就是while死循环没有了,这是因为我们这次使用的是线程池,线程池的线程个数是有限的,如果写死循环的话,那么就只能提供少量连接服务了。所以我们把死循环去掉了,注意最后要关闭sockfd_哦!!!
此外,我们为了解耦,引入了回调函数,这样子就能将业务处理分离。
server.cc
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include<pthread.h>
#include"Threadpool.hpp"
#include"Task.hpp"namespace nt_server
{const uint16_t default_port = 8877; // 默认端口号const std::string default_ip = "0.0.0.0"; // 默认IPconst int backlog = 5; // 请求队列的最大长度enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR};class TcpServer; // 前置声明// 包含我们所需参数的类型class ThreadData{public:ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr):sock_(sock), clientip_(ip), clientport_(port), current_(ptr){}// 设置为公有是为了方便访问public:int sock_;std::string clientip_;uint16_t clientport_;TcpServer* current_; // 指向 TcpServer 对象的指针};using func_t = std::function<std::string(std::string)>; // 回调函数类型class TcpServer{public:TcpServer(const func_t &func, const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port), func_1(func) // 注意这里要传1个业务处理函数{}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建套接字listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Socket Success! " << listen_sock_ << std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(&local, 0, sizeof(local)); // 清零local.sin_family = AF_INET; // 网络local.sin_addr.s_addr = inet_addr(default_ip.c_str()); // 我设置为默认绑定任意可用IP地址local.sin_port = htons(port_); // 我设置为默认是8877if (bind(listen_sock_, (const sockaddr *)&local, sizeof(local)) < 0){std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;exit(BIND_ERR);}if (listen(listen_sock_, backlog) < 0){perror("Listen fail");exit(LIS_ERR);}}// 启动服务器void StartServer(){for (;;){//线程池的设计--》使用之前必须先启动ThreadPool<Task>::GetInstance()->Start();// 1.获取新连接struct sockaddr_in client;socklen_t len = sizeof(client);int accept_socket = accept(listen_sock_, (struct sockaddr *)&client, &len);if (accept_socket < 0){std::cout << "accept failed" << std::endl;continue;}// 2.业务处理// 2.1客户端信息存储uint16_t clientport = ntohs(client.sin_port); // 客户端端口号char clientip[32];inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); // 客户端IPstd::cout << "Server accept " << clientip << " - " << clientport << " " << accept_socket << " from " << listen_sock_ << " success!" << std::endl;// 3.把任务交给线程池Task task1(accept_socket,clientip,clientport,func_1);//和原来Server函数的参数差不多,这里还多传了一个回调函数ThreadPool<Task>::GetInstance()->Push(task1);}}private:int listen_sock_; // socket套接字uint16_t port_; // 端口号std::string ip_; // ip地址func_t func_1; // 业务处理函数};
}
server.cc
#include <string>
#include <vector>
#include <memory> // 智能指针相关头文件
#include <cstdio>
#include "server.hpp"using namespace std;
using namespace nt_server;// 业务处理回调函数(字符串回响)
string echo(string request)
{return request;
}void Usage(const char* program)
{cout << "Usage:" << endl;cout << "\t" << program << " ServerPort" << endl;
}int main(int argc, char* argv[])
{if (argc != 2){// 错误的启动方式,提示错误信息Usage(argv[0]);return USAGE_ERR;}//命令行参数都是字符串,我们需要将其转换成对应的类型uint16_t port = stoi(argv[1]);//将字符串转换成端口号unique_ptr<TcpServer> usvr (new TcpServer(echo,port));usvr->InitServer();usvr->StartServer();return 0;
}
我们可以检测一下
ps -aL | head -1 &&ps -aL | grep server
可以看到一启动就有6个线程——一个主线程,5个是线程池里面的
我们连接看看
我们发现我们发送第一条信息是正常的,但是发第二条信息的时候没有成功,直接告诉我说断开连接了。
这个其实是正常的。因为我们也没有提供常服务,因为资源是有有限的。
1.7.守护进程版服务器
1.7.1.守护进程( Daemon)
守护进程是一种长期运行的进程(守护进程的生存期不一定长,但一般应该这样做),一般是操作系统启动的时候它就启动,操作系统关闭的时候它才关闭。
守护进程跟终端无关联,也就是说它们没有控制终端,所以控制终端退出,也不会导致守护进程退出。
守护进程是在后台运行的,不会占着终端,终端可以执行其他命令。
- 守护进程是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或循环等待处理某些事件的发生;它不需要用户输入就能运行而且提供某种服务,不是对整个系统就是对某个用户程序提供服务。Linux系统的大多数服务器就是通过守护进程实现的。
- 守护进程一般在系统启动时开始运行,除非强行终止,否则直到系统关机才随之一起停止运行;
- 守护进程一般都以root用户权限运行,因为要使用某些特殊的端口(1-1024)或者资源;
- 守护进程的父进程一般都是init进程,因为它真正的父进程在fork出守护进程后就直接退出了,所以守护进程都是孤儿进程,由init接管;
- 守护进程是非交互式程序,没有控制终端,所以任何输出,无论是向标准输出设备stdout还是标准出错设备stderr的输出都需要特殊处理。
- 守护进程的名称通常以d结尾,比如sshd、xinetd、crond等
注意:
- 1.守护进程是一个生存周期较长的进程,通常独立于控制终端并且周期性的执行某种任务或者等待处理某些待发生的事件
- 2.大多数服务都是通过守护进程实现的
- 3.关闭终端,相应的进程都会被关闭,而守护进程却能够突破这种限制
守护进程不会收到来自内核的 SIGHUP 信号,也就是说,如果守护进程收到了 SIGHUP 信号,那么肯定是另外的进程发的。
很多守护进程把 SIGHUP 信号作为通知信号,表示配置文件已经发生改动,守护进程应该重新读入其配置文件。
守护进程不会收到来自内核的 SIGINT 信号(Ctrl+C)、SIGWINCH 信号(终端窗口大小改变)。
1.7.2.进程组
还记得ps指令上面这些是什么吗?
- 这个进程组是什么?
顾名思义,进程组就是一个或多个进程的集合。这些进程并不是孤立的,他们彼此之间或者存在父子、兄弟关系,或者在功能上有相近的联系。每个进程都有父进程,而所有的进程以init进程为根,形成一个树状结构。
那为啥Linux里要有进程组呢?
其实,提供进程组就是为了方便对进程进行管理。假设要完成一个任务,需要同时并发100个进程。当用户处于某种原因要终止 这个任务时,要是没有进程组,就需要手动的一个个去杀死这100个进程,并且必须要严格按照进程间父子兄弟关系顺序,否则会扰乱进程树。有了进程组,就可以将这100个进程设置为一个进程组,它们共有1个组号(pgrp),并且有选取一个进程作为组长(通常是“辈分”最高的那个,通常该进程的ID也就作为进程组的ID)。现在就可以通过杀死整个进程组,来关闭这100个进程,并且是严格有序的。组长进程可以创建一个进程组,创建该组中的进程,然后终止。只要在某个进程组中一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。
进程必定属于一个进程组,也只能属于一个进程组。 一个进程组中可以包含多个进程。 进程组的生命周期从被创建开始,到其内所有进程终止或离开该组。
内核中,sys_getpgrp()系统调用用来获取当前进程所在进程组号;
修改进程组ID的接口如下
int setpgid(pid_t pid, pid_t pgid);
这个函数的含义是,找到进程ID为pid的进程,将这个进程的进程组ID修改为pgid,如果pid的值为0,则表示要修改调用进程的进程组ID。该接口一般用来创建一个新的进程组。
1.7.2.作业
Shell分前后台来控制的不是进程而是作业(Job)或者进程组(Process Group)。一个前台作业可以由多个进程组成,一个后台也可以由多个进程组成,Shell可以运行一个前台作业和任意多个后台作业,这称为作业控制。
作业与进程组的区别:如果作业中的某个进程又创建了子进程,则子进程不属于作业。一旦作业运行结束,Shell就把自己提到前台,如果原来的前台进程还存在(如果这个子进程还没终止),它自动变为后台进程组。
一个或多个进程组的集合,比如用户从登陆到退出,这个期间用户运行的所有进程都属于该会话周期
1.7.3.会话
再看下会话。由于Linux是多用户多任务的分时系统,所以必须要支持多个用户同时使用一个操作系统。当一个用户登录一次系统就形成一次会话 。一个会话可包含多个进程组,但只能有一个前台进程组。每个会话都有一个会话首领(leader),即创建会话的进程。 sys_setsid()调用能创建一个会话。
#include <unistd.h> pid_t setsid(void);
如果这个函数的调用进程不是进程组组长,那么调用该函数会发生以下事情:
- 1)创建一个新会话,会话ID等于进程ID,调用进程成为会话的首进程。
- 2)创建一个进程组,进程组ID等于进程ID,调用进程成为进程组的组长。
- 3)该进程没有控制终端,如果调用setsid前,该进程有控制终端,这种联系就会断掉。
调用setsid函数的进程不能是进程组的组长,否则调用会失败,返回-1,并置errno为EPERM。
这个限制是比较合理的。如果允许进程组组长迁移到新的会话,而进程组的其他成员仍然在老的会话中,那么,就会出现同一个进程组的进程分属不同的会话之中的情况,这就破坏了进程组和会话的严格的层次关系了。
必须注意的是,只有当前进程不是进程组的组长时,才能创建一个新的会话。调用setsid 之后,该进程成为新会话的leader。
一个会话可以有一个控制终端。这通常是登陆到其上的终端设备(在终端登陆情况下)或伪终端设备(在网络登陆情况下)。建立与控制终端连接的会话首进程被称为控制进程。一个会话中的几个进程组可被分为一个前台进程组以及一个或多个后台进程组。所以一个会话中,应该包括控制进程(会话首进程),一个前台进程组和任意后台进程组。
我们使用 XShell
等工具登录 Linux
服务器时,会在服务器中创建一个 会话(bash
),可以在该会话内创建 进程,当 进程 间有关系时,构成一个 进程组,组长 进程的 PID
就是该 进程组 的 PGID
Linux
中的登录操作实际上就是创建了一个会话,Windows
中也是如此,当你的 Windows
变卡时,可以使用 [注销] 按钮结束整个会话,重新登录,电脑就会流畅如初
1.7.5.控制终端
会话的领头进程打开一个终端之后, 该终端就成为该会话的控制终端 (SVR4/Linux)
例如,上面就是4个终端。4个终端登陆的都是同一个账号。
与控制终端建立连接的会话领头进程称为控制进程 (session leader)
一个会话可以有一个控制终端。这通常是登陆到其上的终端设备(在终端登陆情况下)或伪终端设备(在网络登陆情况下)。建立与控制终端连接的会话首进程被称为控制进程。
- 产生在控制终端上的输入和信号将发送给会话的前台进程组中的所有进程
- 终端上的连接断开时 (比如网络断开或 Modem 断开), 挂起信号将发送到控制进程(session leader)
进程属于一个进程组,进程组属于一个会话,会话可能有也可能没有控制终端。一般而言,当用户在某个终端上登录时,一个新的会话就开始了。进程组由组中的领头进程标识,领头进程的进程标识符就是进程组的组标识符。类似地,每个会话也对应有一个领头进程。
同一会话中的进程通过该会话的领头进程和一个终端相连,该终端作为这个会话的控制终端。一个会话只能有一个控制终端,而一个控制终端只能控制一个会话。用户通过控制终端,可以向该控制终端所控制的会话中的进程发送键盘信号。
同一会话中只能有一个前台进程组,属于前台进程组的进程可从控制终端获得输入,而其他进程均是后台进程,可能分属于不同的后台进程组。
当我们打开多个终端窗口时,实际上就创建了多个终端会话。每个会话都会有自己的前台工作和后台工作。
Linux
中一切皆文件,终端文件也是如此,这里的终端其实就是当前 bash
输出结果时使用的文件(也就是屏幕),终端文件位于 dev/pts
目录下,如果向指定终端文件中写入数据,那么对方也可以直接收到
(关联终端文件说白了就是打开了文件,一方写,一方读,不就是管道吗)
1.7.6.创建守护进程的过程
创建守护进程的过程:
- 1)fork()创建子进程,父进程exit()退出
这是创建守护进程的第一步。由于守护进程是脱离控制终端的,因此,完成第一步后就会在Shell终端里造成程序已经运行完毕的假象。之后的所有工作都在子进程中完成,而用户在Shell终端里则可以执行其他命令,从而在形式上做到了与控制终端的脱离,在后台工作。
- 2)在子进程中调用 setsid() 函数创建新的会话
在调用了fork()函数后,子进程全盘拷贝了父进程的会话期、进程组、控制终端等,虽然父进程退出了,但会话期、进程组、控制终端等并没有改变,因此,这还不是真正意义上的独立开来,而 setsid() 函数能够使进程完全独立出来。
- 3)再次 fork() 一个孙进程并让子进程退出
为什么要再次fork呢,假定有这样一种情况,之前的父进程fork出子进程以后还有别的事情要做,在做事情的过程中因为某种原因阻塞了,而此时的子进程因为某些非正常原因要退出的话,就会形成僵尸进程,所以由子进程fork出一个孙进程以后立即退出,孙进程作为守护进程会被init接管,此时无论父进程想做什么都随它了。
- 4)在孙进程中调用 chdir() 函数,让根目录 ”/” 成为孙进程的工作目录
这一步也是必要的步骤,使用fork创建的子进程继承了父进程的当前工作目录。由于在进程运行中,当前目录所在的文件系统(如“/mnt/usb”)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入单用户模式)。因此,通常的做法是让"/"作为守护进程的当前工作目录,这样就可以避免上述的问题,当然,如有特殊需要,也可以把当前工作目录换成其他的路径,如/tmp,改变工作目录的常见函数是chdir。
- 5)在孙进程中调用 umask() 函数,设置进程的文件权限掩码为0
文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限。由于使用fork函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为0,可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是umask。在这里,通常的使用方法为umask(0)。
- 6)在孙进程中关闭任何不需要的文件描述符
同文件权限码一样,用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法卸下。
在上面的第2)步之后,守护进程已经与所属的控制终端失去了联系。因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法(如printf)输出的字符也不可能在终端上显示出来。所以,文件描述符为0、1和2 的3个文件(常说的输入、输出和报错)已经失去了存在的价值,也应被关闭。
- 7)守护进程退出处理
当用户需要外部停止守护进程运行时,往往会使用 kill 命令停止该守护进程。所以,守护进程中需要编码来实现 kill 发出的signal信号处理,达到进程的正常退出。
一张简单的图可以完美诠释之前几个步骤:
1.7.7.直接调用系统现成的接口
使用daemon()函数可以简化守护进程的创建过程。daemon()函数是一个库函数,用于将当前进程转换为守护进程。该函数的原型如下:
#include <unistd.h>int daemon(int nochdir, int noclose);
参数说明:
- nochdir:如果该参数为0,则将当前工作目录更改为根目录;如果为1,则不更改当前工作目录。
- noclose:如果该参数为0,则关闭所有与终端相关的文件描述符;如果为1,则不关闭文件描述符。
一般情况下,daemon()
函数的两个参数都只需要传递 0
,默认工作在 /
路径下,默认重定向至 /dev/null
/dev/null
就像是一个 黑洞,可以把所有数据都丢入其中,相当于丢弃数据
我们怎么改装我们的代码呢?其实特别简单
server.cc
服务器源文件
#include <string>
#include <vector>
#include <memory> // 智能指针相关头文件
#include <cstdio>
#include "server.hpp"using namespace std;
using namespace nt_server;// 业务处理回调函数(字符串回响)
string echo(string request)
{return request;
}void Usage(const char* program)
{cout << "Usage:" << endl;cout << "\t" << program << " ServerPort" << endl;
}int main(int argc, char* argv[])
{if (argc != 2){// 错误的启动方式,提示错误信息Usage(argv[0]);return USAGE_ERR;}// 直接守护进程化daemon(0, 0);//命令行参数都是字符串,我们需要将其转换成对应的类型uint16_t port = stoi(argv[1]);//将字符串转换成端口号unique_ptr<TcpServer> usvr (new TcpServer(echo,port));usvr->InitServer();usvr->StartServer();return 0;
}
只要在这里调用就行了。特别简单
现在服务器启动后,会自动变成 后台进程,并且自成一个 新会话,归操作系统管(守护进程 本质上是一种比较坚强的 孤儿进程)
我们连接看看
我们发现现在是可以通信的。
注意: 现在标准输出、标准错误都被重定向至 /dev/null
中了,之前向屏幕输出的数据,现在都会直接被丢弃,如果想保存数据,可以选择使用日志
我们可以使用kill -9把这个守护进程杀了
1.7.7.自己创建守护进程版本的服务器
手动实现守护进程时需要注意以下几点:
- 忽略异常信号
- 0、1、2 要做特殊处理(文件描述符)
- 进程的工作路径可能要改变(从用户目录中脱离至根目录)
具体实现步骤如下:
- 1、忽略常见的异常信号:SIGPIPE、SIGCHLD
- 2、如何保证自己不是组长? 创建子进程 ,成功后父进程退出,子进程变成守护进程
- 3、新建会话,自己成为会话的 话首进程
- 4、(可选)更改守护进程的工作路径:chdir
- 5、处理后续对于 0、1、2 的问题
对于 标准输入、标准输出、标准错误 的处理方式有两种
- 暴力处理:直接关闭 fd
- 优雅处理:将 fd 重定向至 /dev/null,也就是 daemon() 函数的做法
Daemon.hpp
守护进程头文件
#pragma once#include <iostream>
#include <cstring>
#include <cerrno>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include"Sign.hpp"//存放了退出码等信息static const char *path = "/home/A";//设置守护进程的工作目录,这里大家要自己设置啊void Daemon()
{// 1、忽略常见信号signal(SIGPIPE, SIG_IGN);signal(SIGCHLD, SIG_IGN);// 2、创建子进程,自己退休pid_t id = fork();if (id > 0)exit(0);else if (id < 0){// 子进程创建失败std::cout<<"Fork Fail: "<<strerror(errno)<<std::endl;exit(FORK_ERR);}// 3、新建会话,使自己成为一个单独的组pid_t ret = setsid();if (ret == -1){// 守护化失败std::cout<<"Setsid Fail: "<<strerror(errno)<<std::endl;exit(SETSID_ERR);}// 4、更改工作路径int n = chdir(path);if (n == -1){// 更改路径失败std::cout<<"Chdir Fail: "<<strerror(errno)<<std::endl;exit(CHDIR_ERR);}// 5、重定向标准输入输出错误int fd = open("/dev/null", O_RDWR);if (fd == -1){// 文件打开失败std::cout<<"Open Fail: "<<strerror(errno)<<std::endl;exit(OPEN_ERR);}// 重定向标准输入、标准输出、标准错误dup2(fd, 0);dup2(fd, 1);dup2(fd, 2);close(fd);
}
StartServer()
服务器启动函数 — 位于server.hpp
服务器头文件中的TcpServer
类
#include "Daemon.hpp"// 启动服务器
void StartServer()
{// 守护进程化Daemon();// ...
}
至此,我们完成了
我们直接奉献上源代码
Daemon.hpp
#pragma once#include <iostream>
#include <cstring>
#include <cerrno>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>enum
{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LIS_ERR,CONNECT_ERR,FORK_ERR,SETSID_ERR,CHDIR_ERR,OPEN_ERR
};
//因为这个头文件会被server.hpp包含,server.hpp会被server.cc包含,
//刚好这三个文件里都要使用这些退出码信息,所以放在这里一次即可static const char *path = "/home/zs_108/A";//设置守护进程的工作目录,这里大家要自己设置啊void Daemon()
{// 1、忽略常见信号signal(SIGPIPE, SIG_IGN);signal(SIGCHLD, SIG_IGN);// 2、创建子进程,自己退休pid_t id = fork();if (id > 0)exit(0);else if (id < 0){// 子进程创建失败std::cout<<"Fork Fail: "<<strerror(errno)<<std::endl;exit(FORK_ERR);}// 3、新建会话,使自己成为一个单独的组pid_t ret = setsid();if (ret == -1){// 守护化失败std::cout<<"Setsid Fail: "<<strerror(errno)<<std::endl;exit(SETSID_ERR);}// 4、更改工作路径int n = chdir(path);if (n == -1){// 更改路径失败std::cout<<"Chdir Fail: "<<strerror(errno)<<std::endl;exit(CHDIR_ERR);}// 5、重定向标准输入输出错误int fd = open("/dev/null", O_RDWR);if (fd == -1){// 文件打开失败std::cout<<"Open Fail: "<<strerror(errno)<<std::endl;exit(OPEN_ERR);}// 重定向标准输入、标准输出、标准错误dup2(fd, 0);dup2(fd, 1);dup2(fd, 2);close(fd);
}
Task.hpp
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include<pthread.h>using func_t = std::function<std::string(std::string)>; // 回调函数类型// 任务类
class Task
{
public:Task(int sockfd, const std::string &clientip, const uint16_t &clientport,const func_t &func): sockfd_(sockfd), clientip_(clientip), clientport_(clientport), func_2(func) // 注意这里要传1个业务处理函数{}~Task(){}void Run(){char buff[1024];std::string who = clientip_ + "-" + std::to_string(clientport_);//while (true),如果线程池的线程一直循环在干这件事的话,效率会极其低下ssize_t n = read((sockfd_),buff, sizeof(buff)- 1); // 预留 '\0' 的位置if (n > 0){// 读取成功buff[n] = '\0';std::cout << "Server get: " << buff << " from " << who << std::endl;std::string respond = func_2(buff); // 实际业务处理由上层指定// 发送给服务器write(sockfd_, buff, strlen(buff));}else if (n == 0){// 表示当前读取到文件末尾了,结束读取std::cout << "Client " << who << " " << sockfd_ << " quit!" << std::endl;close(sockfd_); // 关闭文件描述符 }else{// 读取出问题(暂时)std::cerr << "Read Fail!" << strerror(errno) << std::endl;close(sockfd_); // 关闭文件描述符}close(sockfd_);//由于没有死循环,我们必须将其关闭}private:int sockfd_;//accept返回的套接字std::string clientip_;//用户IPuint16_t clientport_;//用户端口号func_t func_2; // 业务处理函数
};
ThreadPool.hpp
#pragma once#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
#include <unistd.h>// 线程信息结构体
struct ThreadInfo
{pthread_t tid; // 线程IDstd::string name; // 线程名称
};// 默认线程数量
static const int defalutnum = 5;// 线程池模板类
template <class T>
class ThreadPool
{
private:// 互斥锁加锁函数void Lock(){pthread_mutex_lock(&mutex_);}// 互斥锁解锁函数void Unlock(){pthread_mutex_unlock(&mutex_);}// 唤醒等待的线程void Wakeup(){pthread_cond_signal(&cond_);}// 线程休眠等待条件变量void ThreadSleep(){pthread_cond_wait(&cond_, &mutex_);}// 判断任务队列是否为空bool IsQueueEmpty(){return tasks_.empty();}// 根据线程ID获取线程名称std::string GetThreadName(pthread_t tid){for (const auto &ti : threads_){if (ti.tid == tid)return ti.name;}return "None";}public:// 线程处理任务的函数static void *HandlerTask(void *args){ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);std::string name = tp->GetThreadName(pthread_self());while (true){tp->Lock();while (tp->IsQueueEmpty()){tp->ThreadSleep();}T t = tp->Pop();tp->Unlock();t.Run();}}// 启动线程池中的所有线程void Start(){int num = threads_.size();for (int i = 0; i < num; i++){threads_[i].name = "thread-" + std::to_string(i + 1);pthread_create(&(threads_[i].tid), nullptr, HandlerTask, this);}}// 从任务队列中取出一个任务T Pop(){T t = tasks_.front();tasks_.pop();return t;}// 向任务队列中添加一个任务void Push(const T &t){Lock();tasks_.push(t);Wakeup();Unlock();}// 获取线程池单例对象static ThreadPool<T> *GetInstance(){if (nullptr == tp_) // 如果线程池对象不存在,则创建一个新的线程池对象{pthread_mutex_lock(&lock_); // 加锁保证线程安全if (nullptr == tp_) // 再次检查是否已经创建了线程池对象,防止多线程环境下的竞争条件{std::cout << "log: singleton create done first!" << std::endl;tp_ = new ThreadPool<T>(); // 创建线程池对象}pthread_mutex_unlock(&lock_); // 解锁}return tp_; // 返回线程池对象指针}private:// 构造函数,初始化线程池,可以指定线程数量,默认为defalutnumThreadPool(int num = defalutnum) : threads_(num){pthread_mutex_init(&mutex_, nullptr); // 初始化互斥锁pthread_cond_init(&cond_, nullptr); // 初始化条件变量}// 析构函数,销毁线程池资源~ThreadPool(){pthread_mutex_destroy(&mutex_); // 销毁互斥锁pthread_cond_destroy(&cond_); // 销毁条件变量}// 禁止拷贝构造和赋值操作符,确保线程池对象的单一性ThreadPool(const ThreadPool<T> &) = delete;const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete; // a=b=c
private:// 线程信息列表std::vector<ThreadInfo> threads_;// 任务队列std::queue<T> tasks_;// 互斥锁和条件变量用于同步和通信pthread_mutex_t mutex_;pthread_cond_t cond_;// 线程池单例对象指针和互斥锁静态成员变量static ThreadPool<T> *tp_;static pthread_mutex_t lock_;
};// 初始化线程池单例对象指针和互斥锁静态成员变量
template <class T>
ThreadPool<T> *ThreadPool<T>::tp_ = nullptr;
template <class T>
pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER;
server.hpp
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include <pthread.h>
#include "Threadpool.hpp"
#include "Task.hpp"
#include "Daemon.hpp"const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP
const int backlog = 5; // 请求队列的最大长度class TcpServer; // 前置声明// 包含我们所需参数的类型
class ThreadData
{
public:ThreadData(int sock, const std::string &ip, const uint16_t &port, TcpServer *ptr): sock_(sock), clientip_(ip), clientport_(port), current_(ptr){}// 设置为公有是为了方便访问
public:int sock_;std::string clientip_;uint16_t clientport_;TcpServer *current_; // 指向 TcpServer 对象的指针
};using func_t = std::function<std::string(std::string)>; // 回调函数类型class TcpServer
{
public:TcpServer(const func_t &func, const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port), func_1(func) // 注意这里要传1个业务处理函数{}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建套接字listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock_ == -1){std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "Create Socket Success! " << listen_sock_ << std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(&local, 0, sizeof(local)); // 清零local.sin_family = AF_INET; // 网络local.sin_addr.s_addr = inet_addr(default_ip.c_str()); // 我设置为默认绑定任意可用IP地址local.sin_port = htons(port_); // 我设置为默认是8877if (bind(listen_sock_, (const sockaddr *)&local, sizeof(local)) < 0){std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;exit(BIND_ERR);}if (listen(listen_sock_, backlog) < 0){perror("Listen fail");exit(LIS_ERR);}}// 启动服务器void StartServer(){// 守护进程化Daemon();for (;;){// 线程池的设计--》使用之前必须先启动ThreadPool<Task>::GetInstance()->Start();// 1.获取新连接struct sockaddr_in client;socklen_t len = sizeof(client);int accept_socket = accept(listen_sock_, (struct sockaddr *)&client, &len);if (accept_socket < 0){std::cout << "accept failed" << std::endl;continue;}// 2.业务处理// 2.1客户端信息存储uint16_t clientport = ntohs(client.sin_port); // 客户端端口号char clientip[32];inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); // 客户端IPstd::cout << "Server accept " << clientip << " - " << clientport << " " << accept_socket << " from " << listen_sock_ << " success!" << std::endl;// 3.把任务交给线程池Task task1(accept_socket, clientip, clientport, func_1); // 和原来Server函数的参数差不多,这里还多传了一个回调函数ThreadPool<Task>::GetInstance()->Push(task1);}}private:int listen_sock_; // socket套接字uint16_t port_; // 端口号std::string ip_; // ip地址func_t func_1; // 业务处理函数
};
server.cc
#include <string>
#include <vector>
#include <memory> // 智能指针相关头文件
#include <cstdio>
#include "server.hpp"using namespace std;// 业务处理回调函数(字符串回响)
string echo(string request)
{return request;
}void Usage(const char* program)
{cout << "Usage:" << endl;cout << "\t" << program << " ServerPort" << endl;
}int main(int argc, char* argv[])
{if (argc != 2){// 错误的启动方式,提示错误信息Usage(argv[0]);return USAGE_ERR;}//命令行参数都是字符串,我们需要将其转换成对应的类型uint16_t port = stoi(argv[1]);//将字符串转换成端口号unique_ptr<TcpServer> usvr (new TcpServer(echo,port));usvr->InitServer();usvr->StartServer();return 0;
}
我们运行一下
完美,收工
其实上面我们所写的TCP服务器实际上是存在很大问题的,比如read的时候,你怎么确定你读到的数据一定是完整的呢?有没有可能对方发来一块数据,你读了一半呢?或者是发来多块数据,你只读了一个半呢?又或者是你直接把所有数据一下子读取上来了呢?
这些情况对于面向字节流的TCP协议来说,都是有可能发生的!和我们以前学的管道一样,写端有可能写了一大批数据,读端有多少读多少,一下子把所有数据都读上来了,一般取决于读端的缓冲区有多大。
对于面向字节流这样不确定的读取该怎么解决呢?实际要通过定制协议来解决! 定制协议这个话题我们先抛出来,我们以后会进行讲解。
2.几个小问题
2.1.实际连接过程没有这么简单
1. 实际上连接的过程并没有我们所想象那么简单,只要客户端调用connect,服务器先调用listen,后调用accept就完成连接过程了,根本不是这么简单的事情!
而connect仅仅只是发起了连接请求,发起连接的请求和真正建立连接这是两码事,你看到一个喜欢的女生,你想要发起追求人家的请求,那和你们俩真正成为男女朋友是一回事吗?当然不是一回事!想要真正成为男女朋友,中间是要有复杂的连接过程的。我们的客户端连接过程同样也是如此。
真正连接的过程实际就是双方操作系统三次握手的过程,这个过程是由双方的操作系统自动完成的。 我们知道上层发起连接请求和收获连接结果是通过connect和accept系统调用来完成的,而真实的连接过程和这两个系统调用没什么关系,连接过程是由双方的操作系统执行各自的内核代码自动完成连接过程的。
所以accept并不参与三次握手的任何细节,他仅仅只负责拿走连接结果的胜利果实。换句话说,就算上层不调用accept,三次握手的过程也能够建立好,因为应用是应用,底层是底层,三次握手就是底层,和你应用没半毛钱关系,这是我双方的操作系统自主完成的工作。
另外我们所说的TCP协议保证可靠性和应用有关系吗?照样没半毛钱关系!因为应用是应用,底层是底层,TCP协议是传输层的,传输层在操作系统内部实现。
相同的,四次挥手的过程也是由双方操作系统完成的,而close(sockfd)的作用仅仅只是触发了四次挥手而已。
2.2.维护TCP的连接有成本吗?
我们知道肯定不可能只有一个客户端连接服务器,如果是多个客户端连接服务器的话,服务器要不要对这么多的连接请求做管理呢?反过来一个客户端如果连接了多个服务器的话,那么多个服务器返回的连接结果,客户端要不要做管理呢?当然是要的! 所以客户端和服务器都要对大量的连接请求做管理,那该怎么做管理呢?
先描述,再组织!双方的操作系统内部一定维护了连接请求所对应的内核结构对象,描述特定的某个连接的属性信息,然后再用数据结构将这些对象连接起来进行管理,至此我们就完成了从表层泛泛而谈的连接到内核这一层的理解过程。
所以维护TCP的连接有成本吗?
一定是有的,因为双方的操作系统要在各自底层建立描述连接的结构对象,然后用数据结构将这些结构对象管理起来,这些都是要花时间和内存空间的,所以维护连接一定是有成本的。
2.3.简单理解三次握手和四次挥手
我们知道,tcp是面向连接的,客户端和服务端要先建立链接,才能开始通信
- 在链接过程中,tcp采用三次握手
- 在断线过程中,tcp采用四次挥手
举个日常生活中的栗子,帮助理解3次握手和4次挥手
接下来我们先来简单介绍一下三次握手和四次挥手
三次握手:client调用connect,向服务器发起连接请求,connect会发出SYN段并阻塞等待服务器应答(第一次),服务器收到客户端的SYN段后,会给客户端应答一个SYN-ACK段表示"同意建立连接"(第二次),客户端收到SYN-ACK段后会从connect系统调用返回,同时应答一个ACK段(第三次),此时连接建立成功。
四次挥手:客户端如果没有请求之后,就会调用close关闭连接,此时客户端会向服务器发送FIN段(第一次),服务器收到FIN段后,会回应一个ACK段(第二次),同时服务器的read会读到0,当read返回后服务器就会知道客户端关闭了连接,他此时也会调用close关闭连接,同时向客户端发送FIN段(第三次), 客户端收到FIN段后,会给服务器返回一个ACK段(第四次)。 (socketAPI的connect被调用时会发出SYN段,read返回时表明服务器收到FIN段)
而四次挥手与三次握手有所不同,三次握手是某一方先发起连接请求然后进行连接,断开连接是双方的事情,client对server说我要和你断开连接,server说好呀,我同意,然后server又对client说,我也要和你断开连接,client说OK,我也同意,至此才完成了断开连接的过程。所以断开连接是双方的事情,少了任何一方都只能算作通知,只有双方共同协商才能完成断开连接的过程。
2.4.TCP通信的实质
我们之前创建的套接字,实际上sockfd会指向一个操作系统给分配好的socket file control block(socket文件控制块),而这个socket文件控制块内部会维护网络发送和网络接收的缓冲区,我们调用的所有网络发送函数,write send sendto等实际就是将数据从应用层缓冲区拷贝到TCP协议层,也就是操作系统内部的发送缓冲区,而网络接收函数,read recv recvfrom等实际就是将数据从TCP协议层的接收缓冲区拷贝到用户层的缓冲区中,而实际双方主机的TCP协议层之间的数据发送是完全由TCP自主决定的,什么时候发?发多少?发送时出错了怎么办?
这些全部都是由TCP协议自己决定的,这是操作系统内部的事情,和我们用户层没有任何瓜葛,这也就是为什么TCP叫做传输控制协议的原因,因为传输的过程是由他自己所控制决定的。
c->s和s->c之间发送使用的是不同对的发送和接收缓冲区,所以c给s发是不影响s给c发送的,这也就能说明TCP是全双工的,一个在发送时,不影响另一个也再发送,所以网络发送的本质就是数据拷贝。
- 应用层缓冲区是什么?
说应用层缓冲区怕大家感觉到抽象,其实所谓的应用层缓冲区就是我们自己定义的buffer,可以看到下面的6个网络发送接收接口都有对应的buf形参,我们在使用的时候肯定要传参数进去,而传的参数就是我们在应用层所定义出来的缓冲区。
这里多说一句,上面的六个接口在进行网络发送和网络读取数据的时候,都会做网络字节序和主机字节序之间的转换,recvfrom和sendto是程序员自己显示做转换,其余的四个接口是操作系统自动做转换,这是铁铁的事实!网上有人会说其余的四个接口不会做转换,这是错误的!他们一定会做转换的,因为不转换网络通信时一定会出现问题的,但事实上他们并不会出现问题,所以调用下面这六个接口时,一定都会做网络字节序和主机字节序之间的转换。(gpt也是从网络中爬出来的数据,他说的不一定是对的,不要完全相信gpt!)
3.封装接口
由于在后面使用的频率很高,我们得将其进行封装
Socket.hpp
#pragma once #include <iostream>
#include <string>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h> // 定义一些错误代码
enum
{ SocketErr = 2, // 套接字创建错误 BindErr, // 绑定错误 ListenErr, // 监听错误
}; // 监听队列的长度
const int backlog = 10; class Sock //服务器专门使用
{
public: Sock() : sockfd_(-1) // 初始化时,将sockfd_设为-1,表示未初始化的套接字 { } ~Sock() { // 析构函数中可以关闭套接字,但这里选择不在析构函数中关闭,因为有时需要手动管理资源 } // 创建套接字 void Socket() { sockfd_ = socket(AF_INET, SOCK_STREAM, 0); if (sockfd_ < 0) { printf("socket error, %s: %d", strerror(errno), errno); //错误 exit(SocketErr); // 发生错误时退出程序 } int opt=1;setsockopt(sockfd_,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt)); //关闭后快速重启} // 将套接字绑定到指定的端口上 void Bind(uint16_t port) { //让服务器绑定IP地址与端口号struct sockaddr_in local; memset(&local, 0, sizeof(local));//清零 local.sin_family = AF_INET; // 网络local.sin_port = htons(port); // 我设置为默认绑定任意可用IP地址local.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口 if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) < 0) //让自己绑定别人{ printf("bind error, %s: %d", strerror(errno), errno); exit(BindErr); } } // 监听端口上的连接请求 void Listen() { if (listen(sockfd_, backlog) < 0) { printf("listen error, %s: %d", strerror(errno), errno); exit(ListenErr); } } // 接受一个连接请求 int Accept(std::string *clientip, uint16_t *clientport) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int newfd = accept(sockfd_, (struct sockaddr*)&peer, &len); if(newfd < 0) { printf("accept error, %s: %d", strerror(errno), errno); return -1; } char ipstr[64]; inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr)); *clientip = ipstr; *clientport = ntohs(peer.sin_port); return newfd; // 返回新的套接字文件描述符 } // 连接到指定的IP和端口——客户端才会用的 bool Connect(const std::string &ip, const uint16_t &port) { struct sockaddr_in peer;//服务器的信息 memset(&peer, 0, sizeof(peer)); peer.sin_family = AF_INET; peer.sin_port = htons(port);inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr)); int n = connect(sockfd_, (struct sockaddr*)&peer, sizeof(peer)); if(n == -1) { std::cerr << "connect to " << ip << ":" << port << " error" << std::endl; return false; } return true; } // 关闭套接字 void Close() { close(sockfd_); } // 获取套接字的文件描述符 int Fd() { return sockfd_; } private: int sockfd_; // 套接字文件描述符
};
- setsockopt
setsockopt 函数是用于设置套接字选项的重要工具。通过这个函数,开发者可以调整套接字的行为,以满足特定的网络应用需求。
(1)函数原型
int setsockopt(SOCKET s,int level,int optname,const char *optval,int optlen );
(2)函数原型
- s:套接字描述符,标识要设置选项的套接字。
- level:选项定义的层次。常见的层次有 SOL_SOCKET(通用套接字选项)和 IPPROTO_TCP(TCP协议相关选项)。
- optname:要设置的选项名称。这个参数必须是在指定层次内定义的有效选项。
- optval:指向包含选项值的缓冲区的指针。
- optlen:optval 指向的缓冲区的大小(以字节为单位)。
(3)返回值
- 如果函数成功执行,返回 0;否则,返回 SOCKET_ERROR,并可以通过调用 WSAGetLastError 函数来获取具体的错误代码。
(4)常见选项
以下是一些常见的套接字选项,可以通过 setsockopt 函数进行设置:
SOL_SOCKET 层选项
- SO_DEBUG:调试选项。启用后,会记录有关套接字操作的调试信息。
- SO_REUSEADDR:地址重用选项。启用后,套接字可以在关闭后立即重新使用其地址,而不必等待 TIME_WAIT 状态结束。这对于快速重启服务器很有用。
- SO_RCVBUF 和 SO_SNDBUF:接收和发送缓冲区大小。这两个选项允许你设置套接字的接收和发送缓冲区的大小。注意,这只是一个建议值,实际大小可能因操作系统和可用内存而异。
- SO_KEEPALIVE:保持活动选项。启用后,套接字会定期发送保持活动消息,以检查连接是否仍然有效。
- SO_LINGER:延迟关闭选项。用于控制套接字在关闭时的行为。如果设置了此选项,并且套接字上有未发送的数据,系统会尝试发送这些数据,直到超时或所有数据都发送完毕。
- SO_OOBINLINE:带外数据内联选项。如果启用,带外数据(即紧急数据)将作为普通数据流的一部分接收,而不是通过单独的接口。
- SO_BROADCAST:广播选项。允许套接字发送广播消息。
IPPROTO_TCP 层选项
- TCP_NODELAY:禁用 Nagle 算法。默认情况下,TCP 使用 Nagle 算法来合并小的数据包以提高网络效率。但有时,你可能希望立即发送数据,即使它很小。设置此选项可以禁用 Nagle 算法。
- TCP_MAXSEG:最大段大小。这用于设置 TCP 最大传输单元(MTU)探测的大小。通常,这不需要手动设置,因为系统会自动处理。
IPPROTO_IP 层选项
- IP_MULTICAST_IF:多播接口选项。用于指定用于发送多播数据包的接口。
- IP_MULTICAST_TTL:多播生存时间选项。设置多播数据包在网络中可以跳过的最大路由器数。
- IP_ADD_MEMBERSHIP 和 IP_DROP_MEMBERSHIP:加入和退出多播组。这些选项允许你指定套接字应该接收哪些多播地址的数据包。
有了上面这个套接字,我们就可以简化我们的Tcpserver.hpp
server.hpp
const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IPclass TcpServer
{
public:TcpServer(const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port){}~TcpServer(){}bool InitServer(){listensock_.Socket();listensock_.Bind(port_);listensock_.Listen(); }void Start(){while(true){std::string clientip;uint16_t clientport;int sockfd=listensock_.Accept(&clientip,&clientport);//这里会返回一个新的套接字if(socket<0) continue;//提供服务if(fork()==0){listensock_.Close();//通过sockfd使用提供服务std::string inbuf;while (1){char buf[1024];// 1.读取客户端发送的信息ssize_t s = read(sockfd, buf, sizeof(buf) - 1);if (s == 0){ // s == 0代表对方发送了空消息,视作客户端主动退出printf("client quit: %s[%d]", clientip.c_str(), clientport);break;}else if (s < 0){// 出现了读取错误,打印错误后断开连接printf("read err: %s[%d] = %s", clientip.c_str(), clientport, strerror(errno));break;}else // 2.读取成功{}}exit(0);//子进程退出}close(sockfd);//}}private:uint16_t port_;Sock listensock_;//专门用来listen的std::string ip_; // ip地址
}