文章目录
TCP套接字概述
TCP套接字提供了面向连接的 , 面向字节流的 , 传输可靠的传输方式;
-
面向连接的
TCP是一种面向连接的协议 , 在通信开始之前需要建立联系;
这个连接通过三次握手过程建立以确保通信双方已经准备好进行数据传输;
-
面向字节流
TCP传输的数据被看作是连续的字节流而不是消息或数据包;
这意味应用程序可以将数据作为字节流发送 , TCP将会负责这些字节流按照顺序传送给接收方 , 接收方也会以同样的顺序接收数据;
面向字节流也意味着可以直接使用
write
/read
来对该套接字描述符进行操作; -
传输可靠
TCP提供可靠的数据传输服务 , 以确保数据包的准确和完整到达;
通过确认机制 , 重传机制和序列号来保证数据的正确性和完整性;
在进行TCP套接字网络通信程序的编写可以将服务端和客户端分别封装为一个类以方便管理以及使用;
服务端整体结构
cpp
class TcpServer {
public:
// 构造函数初始化 TcpServer 类
TcpServer(uint16_t port = defaultport) : sockfd_(-1) , port_(port) {}
void Init() {
// 创建套接字 绑定 监听
}
void Start() {
// 获取连接 处理客户端请求
}
~TcpServer() {
// 清理操作
}
private:
int sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
服务端的整体结构为封装一个服务端的类;
这个类的成员如下:
-
构造函数
用于初始化该类对象 , 主要是初始化套接字描述符与端口号;
套接字描述符默认初始化为
-1
, 表示无效值; -
析构函数
这里的析构函数将用于关闭套接字描述符;
-
Init()
这个成员函数主要是用来初始化服务端对象的基本信息 , 包括创建TCP套接字 , 绑定 , 监听等操作;
-
Start()
这个函数主要是用来运行服务端 , 在运行过程中将会获取来自客户端的连接以及处理客户端请求等等;
-
sockfd_
这个成员变量用于保存服务端套接字的描述符;
-
port_
这个成员变量用来保存服务端的端口号;
-
defaultport
这个成员变量是一个
const
静态成员变量 , 这个成员变量定义了一个默认的端口号为8080
;
整体的代码结构与UDP网络通信一致(『 Linux 』利用UDP套接字简单进行网络通信);
服务端套接字创建
无论是哪一种网络通信都必须使用套接字 , 所以建立网络通信的第一步就是创建套接字;
套接字的创建调用socket()
接口 , 函数原型为:
cpp
int socket(int domain , int type , int protocol);
-
domain
这个参数表示传入对应的协议家族为
IPv4
, 其对应的参数为AF_INET
; -
type
这个参数表示套接字类型;
与
UDP
不同 , 使用UDP
时传入的参数为SOCK_DGRAM
表示使用数据报套接字;TCP
是面向字节流的协议类型 , 传入的参数为SOCK_STREAM
, 表示提供有序的 , 可靠的 , 面向字节流的传输服务;传入
SOCK_STREAM
; -
protocol
这个参数表示指定具体的协议 , 设置为
0
表示自动选择合适的协议;
cpp
Log lg;
enum { SOCKET_ERR = 2 };
class TcpServer {
public:
void Init() {
// 套接字创建
sockfd_ = socket(AF_INET , SOCK_STREAM , 0);
if (sockfd_ < 0) {
lg(FATAL , "create socket error , error message: %s" , strerror(errno));
exit(SOCKET_ERR);
}
lg(INFO , "create socket sucess , sockfd:%d" , sockfd_);
}
private:
int sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
这里的Log lg
是引入了自定义的日志文件 , 具体是用来打印日志信息的(『 Linux 』简单日志插件) , 这里可以忽略或是使用stderror
直接打印错误信息来代替;
退出信息采用了枚举的方式提高代码可读性;
当socket
函数调用成功时将会返回一个非零值 , 这个非零值是套接字描述符;
当调用失败时将会返回-1
并设置错误码errno
, 当调用失败时则表示网络通信最基本的需求都不满足 , 即套接字创建失败 , 此时直接打印错误信息并退出即可;
服务端套接字的绑定
套接字的创建并没有直接与网络相关联 , 只是打开了一个网络对应的文件(套接字) , 并获得了这个套接字的描述符;
在创建套接字后必须将该套接字进行bind
来绑定这个服务器的套接字信息;
-
创建协议族结构体
在进行
bind
时需要创建对应的协议族结构体struct sockaddr_in
, 并填充对应的信息 , 填充的信息通常为服务端的协议家族 , 服务端的IP
, 服务端的端口号; -
协议族结构体信息填充
cppstruct sockaddr_in { sa_family_t sin_family; // 地址族(应设置为 AF_INET) in_port_t sin_port; // 端口号(使用 htons 转换为网络字节序) struct in_addr sin_addr; // IP地址(使用 in_addr 结构表示) char sin_zero[8]; // 填充字段,通常设置为 0 };
这段代码是
sockaddr_in
结构体的结构;需要填充的网络信息为协议族
sin_family
, 端口号sin_port
和IP
地址sin_addr
;其中剩下的
sin_zero[8]
为补充字段 , 通常情况下可以不填 , 这里可以直接忽略;-
sin_family
设置为
AF_INET
表示使用IPv4
; -
sin_port
设置为这个服务端类的成员变量
port_
表示服务端的端口号 , 这个端口号在构造函数中的初始化列表进行初始化 , 默认设置为8080
;这个端口号是要被发送到网络中的 , 最终会被客户端读取 , 所以端口号需要进行主机字节序转网络字节序的操作;
-
sin_addr
设置这个服务端
IP
地址 , 同样的IP
地址需要进行主机字节序转网络字节序的操作 , 同时IP
地址需要从点分十进制转换为四字节(所传入的IP地址是一个string
类型 , 为点分十进制);通常一台计算机中可能存在多个网卡设备 , 即可能存在多个
IP
地址 , 绑定这个IP
地址可以指定服务端从哪个IP
地址(网卡)中读取网络数据(前提是需要知道自己的计算机中有多少个IP
地址 , 在IPv4中可以使用ipconfig
来查看对应的公网IP
);也可以使用本地环回地址(
127.0.0.1
-127.0.0.8
)用于本地测试;对于云服务器而言 , 在进行网络编程中不能绑定自己的公网
IP
(一般情况下云服务器中的公网IP可能不是实际的公网IP以保证服务器的安全) , 但可以使用环回地址进行本地操作或是将IP
设置为INADDR_ANY
表示可以从该计算机中的任意网卡中读取网络数据;INADDR_ANY
为0
, 即为0.0.0.0
;通常绑定
INADDR_ANY
是一个比较推荐的做法;
-
-
bind
绑定上面的操作只是创建对应的协议族结构体并填充对应信息 , 并未将实际的操作进行绑定 , 同时这个创建的协议族结构体只是在用户区中 , 并没有写进操作系统内部;
所以在创建协议族结构体并填充好信息后还需要将对应的协议族结构体与创建的套接字调用
bind
进行绑定;bind
函数原型为:cppint bind(int sockfd , const struct sockaddr *addr , socklen_t addrlen);
-
sockfd
表示传入服务端的套接字描述符;
-
addr
表示传入刚刚创建并填充好的
struct sockaddr_in
结构体指针;由于类型不同 , 这里传入指针的时候需要进行强制类型转换为
struct sockaddr*
; -
addrlen
表示传入刚刚创建并填充好的
struct sockaddr_in
结构体的大小;
-
cpp
Log lg;
enum { SOCKET_ERR = 2 ,
BIND_ERR };
class TcpServer {
public:
void Init() {
// 套接字创建
sockfd_ = socket(AF_INET , SOCK_STREAM , 0);
if (sockfd_ < 0) {
lg(FATAL , "create socket error , error message: %s" , strerror(errno));
exit(SOCKET_ERR);
}
lg(INFO , "create socket sucess , sockfd:%d" , sockfd_);
// 绑定
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;
socklen_t len = sizeof(local);
if (bind(sockfd_ , (sockaddr*)&local , len) < 0) {
lg(FATAL , "bind error , error message: %s" , strerror(errno));
exit(BIND_ERR);
}
}
private:
int sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
创建协议族结构体后可以将结构体进行清空初始化以避免传入后没有初始化导致的未定义行为 , 清空操作可以调用bzero
函数或是memset
函数进行;
在填充端口号信息的时候调用了htons
来进行端口号的转网络字节序操作;
同时在填充sin_addr
时由于这个成员是一个结构体 , 在直接对这个信息进行填充的时候具体要填的是这个结构体中的s_addr
成员 , 由于填入的信息为INADDR_ANY
即0
, 不需要再进行转网络字节序;
在信息填充后需要调用bind
进行绑定 , 这个函数调用成功时会返回0
, 调用失败将会返回-1
并设置errno
错误码;
这里使用了自定义的日志插件打印bind
失败的错误信息;
当bind
失败后就没必要再继续往下执行了 , 和套接字的创建相同 , bind
失败同样代表网络资源未就绪无法进行网络通信 , 直接退出即可;
exit
的退出码同样的设置在enum
枚举中;
服务端监听
与UDP不同 , TCP是面向连接的协议 , 面向连接的协议需要在建立通信前先建立连接 , 服务端是比较"被动"的 , 这意味着服务端需要随时监听可能来自客户端的连接请求;
而UDP程序不需要建立连接 , 所以只需要bind
后就能进行通信 , TCP的服务端需要使用listen
监听;
-
listen
函数原型如下cppint listen(int sockfd , int backlog);
这个函数的功能是将一个套接字描述符对应的套接字设置为监听状态;
被设置为监听状态的套接字可以随时等待新连接的到来;
函数调用成功时将会返回
0
, 调用失败时将返回-1
并设置errno
错误码;-
sockfd
这个参数表示传入一个需要设置为监听状态套接字的描述符 , 这里需要传入的是之前创建的TCP套接字对应的描述符;
-
backlog
这个参数指定了内核为该套接字维护的连接队列的最大长度 , 通常这个值不要设置的太大 , 可以设置为
5
或10
;
-
cpp
Log lg;
enum { SOCKET_ERR = 2 , BIND_ERR , LISTEN_ERR };
const int backlog = 5; // 连接队列最大长度
class TcpServer {
public:
void Init() {
// 套接字创建
listen_sockfd_ = socket(AF_INET , SOCK_STREAM , 0);
if (listen_sockfd_ < 0) {
lg(FATAL , "create socket error , error message: %s" , strerror(errno));
exit(SOCKET_ERR);
}
lg(INFO , "create socket sucess , sockfd:%d" , listen_sockfd_);
// 绑定
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;
socklen_t len = sizeof(local);
if (bind(listen_sockfd_ , (sockaddr*)&local , len) < 0) {
lg(FATAL , "bind error , error message: %s" , strerror(errno));
exit(BIND_ERR);
}
// 设置监听
if (listen(listen_sockfd_ , backlog) < 0) {
lg(FATAL , "listen error , error message: %s" , strerror(errno));
exit(LISTEN_ERR);
}
}
private:
int listen_sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
TCP通信时服务端的套接字需要被设置为监听状态 , 故这个套接字是监听套接字 , 为了提高代码可读性这里将成员变量sockfd_
修改为listen_sockfd_
;
同样的监听listen
调用成功时返回0
, 调用失败时返回-1
并设置errno
;
当调用失败后就没必要继续向下执行 , 因为调用失败则表示监听失败 , 无论客户端是否向服务端发起连接 , 服务端都无法监听 , 所以打印出错误信息并退出程序即可;
服务端初始化测试
当服务端的套接字创建成功 , 绑定本地信息 , 设置监听状态后就表示服务端的网络资源已经就绪 , 监听状态表示服务端可以随时监听来自客户端的请求;
这里可以简单测试一下服务端是否能否成功运行;
在测试服务端的时候 , 需要一个IP地址和一个端口号 , 由于这里使用的是云服务器 , 对应的IP地址采用的是INADDR_ANY
表示接收这个计算机中所有网络接口卡接收来的数据 , 所以可以不再需要传入对应的IP地址 , 但端口号是一定要暴露出来的;
端口号的暴露才能让客户端通过 IP + 端口 的方式进入;
这里的Start
函数还没有实现 , 为了确保在对应主函数中可以通过TcpServer->Init()
与TcpServer->Start()
的方式运行来观察服务端的初始化(包括套接字建立 , 绑定 , 监听)操作是否成功 , 这里的Start
函数设计为一个while
循环 , 每一秒打印一次信息;
cpp
Log lg;
enum { SOCKET_ERR = 2 , BIND_ERR , LISTEN_ERR };
const int backlog = 5;
class TcpServer {
public:
void Init() {
// 套接字创建
listen_sockfd_ = socket(AF_INET , SOCK_STREAM , 0);
if (listen_sockfd_ < 0) {
lg(FATAL , "create socket error , error message: %s" , strerror(errno));
exit(SOCKET_ERR);
}
lg(INFO , "create socket sucess , sockfd:%d" , listen_sockfd_);
// 绑定
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;
socklen_t len = sizeof(local);
if (bind(listen_sockfd_ , (sockaddr*)&local , len) < 0) {
lg(FATAL , "bind error , error message: %s" , strerror(errno));
exit(BIND_ERR);
}
// 设置监听
if (listen(listen_sockfd_ , backlog) < 0) {
lg(FATAL , "listen error , error message: %s" , strerror(errno));
exit(LISTEN_ERR);
}
}
void Start() {
// 获取连接 处理客户端请求
while (1) {
lg(INFO , "TcpServer start sucess...");
sleep(1);
}
}
~TcpServer() {
// 清理操作
if(listen_sockfd_ != -1){
close(listen_sockfd_);
}
}
private:
int listen_sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
这里的析构函数用来资源清理 , 当调用socket
函数创建套接字时会返回一个套接字描述符;
当使用结束后可以调用析构函数来关闭套接字描述符防止套接字描述符的过多占用;
主函数的主要框架就是实例化一个这样的服务端实例 , 然后调用这个实例对象的Init()
和Start()
方法;
cpp
/* server.cc */
void Usage() { printf("\n\tUsage : ./srever port[port>1024]\n\n"); }
int main(int argc , char *argv[]) {
if (argc != 2) {
Usage();
exit(-1);
}
uint16_t port = std::stoi(argv[1]);
TcpServer ts(port);
ts.Init();
ts.Start();
return 0;
}
在主函数中将会通过命令行参数来判断服务端的使用者在启动服务端时有没有按照约定来给定端口号 , 如果命令行个数argc
没有达到2
就表示没有按照约定来传递命令行参数 , 这时候会调用Usage
来为使用者提供使用手册;
主函数接收到命令行参数为2
时会把下标为1
的命令行参数(第二个)作为端口号来执行TcpServer
服务端的初始化操作;
在初始化操作后会调用Init()
和Start()
成员函数来进行测试;
-
编译后的运行结果为:
cpp$ ./server 8080 [INFO][2024-08-26 10:43:04] create socket sucess , sockfd:3 [INFO][2024-08-26 10:43:04] TcpServer start sucess... [INFO][2024-08-26 10:43:05] TcpServer start sucess...
运行的消息是
INFO
类型的通常消息 , 不为WARNING
和FATAL
类型的消息表示套接字的创建 , 绑定以及监听操作没有出现任何异常 , 同样的这里打印的内容是使用日志插件的 , 可以忽略(没使用自定义日志插件但使用了strerror(errno)
等函数可参考打印的错误信息);
在创建套接字后返回的套接字描述符是3
, 说明它和文件描述符是同级的 , 套接字属于是一个网络文件 , 其中这里的描述符0 , 1 , 2
是默认被占用的 , 分别代表标准输入 , 标准输出和标准错误;
在程序的运行过程中可以使用netstat -nltp
来查看响应的网络信息 , 这里的n
代表把能表示为十进制数字的以十进制数字表示 , l
表示查看当前为listen
状态的网络套接字信息 , t
表示查协议使用TCP
的网络套接字信息 , p
表示把使用这个套接字信息的进程PID
显示出来;
cpp
$ netstat -nltp
(Not all processes could be identified , non-owned process info
will not be shown , you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 8338/./server
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:42715 0.0.0.0:* LISTEN 4010/node
其中这里的PID/Program name
为8338/./server
的进程就是正在使用这个套接字的进程 , 这个套接字绑定的IP
与端口号为0 0.0.0.0:8080
, 状态为监听状态;
这些现象表明此时的服务端可以接收任何来自客户端的连接;
服务端获取新连接
服务端创建了套接字 , 进行了绑定与设置监听表明服务端可以接收任何来自客户端的连接 , 但是这里只是具备了这个条件 , 没有真正接收来自客户端的连接并建立连接的动作;
因为TCP协议是面向连接的 , 服务端不仅要能监听来自客户端的连接 , 还需要获取来自客户端的连接 , 服务端才能响应客户端的请求;
服务端需要调用accept()
函数来获取来自客户端的连接;
-
accept()
函数原型如下cppint accept(int sockfd , struct sockaddr *addr , socklen_t *addrlen);
这个函数调用成功时返回一个套接字描述符 , 这个套接字描述符表示服务端接收的客户端连接的套接字描述符 , 这个程序中
accept
返回的套接字描述符为4
, 因为0 , 1 , 2
默认被占用 ,3
被监听套接字占用 , 所以这里返回值通常是4
;调用失败则返回
-1
并设置错误码errno
;-
sockfd
这个参数表示传入最初服务端创建并设置为监听状态的套接字描述符 , 表示从这个套接字描述符中获取来自客户端的连接;
-
*addr
这个参数是一个输出型参数 , 传入一个
struct sockaddr_in
结构体的指针 , 这里的指针为匹配类型需要强制转换为struct sockaddr*
, 当服务端获取客户端的连接时需要知道客户端对应的套接字信息 , 而套接字信息被存储在这个struct sockaddr
中 , 意思就是调用这个函数成功之后客户端的套接字信息将会被写入到这个结构体当中; -
*addrlen
这个参数同样是一个输出型参数 , 当这个函数调用成功后
struct sockaddr
结构体的大小将会被写进addrlen
参数中;
cppLog lg; enum { SOCKET_ERR = 2 , BIND_ERR , LISTEN_ERR }; const int backlog = 5; class TcpServer { public: void Start() { lg(INFO , "TcpServer start sucess..."); while (true) { // 获取连接 struct sockaddr_in local; memset(&local , 0 , sizeof(local)); socklen_t len = sizeof(local); int sockfd = accept(listen_sockfd_ , (struct sockaddr*)&local , &len); if (sockfd < 0) { lg(WARNING , "accept error , error message: %s" , strerror(errno)); continue; } else { lg(INFO , "accept sucess , sockfd : " , sockfd); } } } private: int listen_sockfd_; // 服务端的监听套接字描述符 uint16_t port_; // 服务端的监听端口 protected: static const uint16_t defaultport; // 默认端口号 }; const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
获取连接并处理客户端的请求可能是一个持续的操作 , 所以这里设置了一个
while
循环 ,同时在获取客户端的连接时一定可能出现调用
accept
失败 , 即获取失败的结果 , 但是服务端不能因为一次获取连接失败就直接中断程序 , 否则必然会导致其他已经建立连接的客户端出现错误(客户端正常运行同时与服务端保持连接时服务端崩了) , 所以accept
的调用失败不能是一个FATAL
致命级别的错误 , 而是一个WARNING
级别的错误 , 当调用失败时需要continue
尝试再次获取来自客户端的连接; -
服务端调用accept
所获取的套接字描述符与服务端本身创建的套接字所发挥的作用是不同的 , 服务端本身创建的套接字的作用是用来监听是否有客户端的连接要过来 , 而调用accept
所返回的套接字描述符套接字是用来接收从服务器本身创建的套接字描述符过来的套接字信息 , 意思就是调用accept
函数所返回的套接字描述符才是真正用来进行通信的套接字;
这也意味着在一个服务端程序中 , 监听套接字描述符始终只有一个 , 但是accept
返回的用来通信的套接字描述符可能会越来越多;
这里可以进行一个小测试 , 当服务端接收到来自一个客户端的连接时将对应的套接字描述符进行打印 , 作为简单的数据处理;
测试的小工具使用操作系统自带的telnet
工具;
telnet
是系统自带的一个用于测试网络通信的一个工具 , 使用的方式为:
cpp
telnet [IP] [port]
表示与目标主机建立网络通信 , telnet
是一个专门用来测试TCP通信的工具 , 在代码中服务端将监听是否有来自其他机器过来的连接 , 如果有则调用accept
函数来获取对端发送来的套接字信息并返回一个套接字描述符(网络文件描述符) , 如果调用失败则返回-1
, 使用telnet -127.0.0.1 8888
的方式表示使用telnet
工具利用TCP协议与服务端建立连接 , 如果获取连接失败将会显示出WARNING
级别的日志信息并且重新尝试获取连接 , 获取连接成功则会打印出INFO
级别的日志信息并且打印出返回的套接字描述符;
在这里传入了8080
作为服务端绑定的端口号 , 再使用另一个云服务器使用telnet
工具通过IP地址与端口号连接到服务端 , 尝试连接后服务端监听到了来自另一个云服务器的telnet
的连接 , 打印出对应的日志信息与接收到的套接字描述符 , 这里的套接字描述符为4
与预期相同;
因为TCP是面向连接的传输服务 , 当服务端主动中断程序 , 对应的客户端(telnet
)无法与服务端保持连接将自动退出 , 这是由于当服务端关闭时客户端再读取服务端的响应时将会读到0
, 可以在接下来的程序中定义客户端对应的行为来处理连接被关闭的情况;
在使用telnet
工具的时候也可以在本地使用环回地址(127.0.0.1
-127.0.0.8
)进行本地测试 , 即在同一服务器下的不同会话中使用telnet [环回地址] [port]
的方式进行测试;
服务端处理客户端请求
一个服务端在接收到客户端的请求时一般都需要处理客户端的请求 , 最终将处理后的请求返回给客户端;
为了提高代码的可维护性 , 可以单独将服务端的响应单独封装为一个函数;
在处理客户端的请求之前 , 服务端可能需要知道客户端对应的套接字信息 , 而客户端的套接字信息已经被服务端使用accept
函数获取连接时一并获取 , 即为在调用accept
函数传入的输出型参数struct sockaddr_in local
, 这个结构体中已经被写入了客户端的IP地址以及端口号 , 只需要进行提取即可 , 但是在提取的时候为保证字节序相同需要进行网络字节序转主机字节序 , 对于端口而言可以直接使用ntohs
进行网络字节序转主机字节序 , 而对于IP地址而言不仅需要进行网络字节序转主机字节序 , 还需要进行四字节转点分十进制的操作;
cpp
class TcpServer {
public:
void Start() {
// 获取连接 处理客户端请求
lg(INFO , "TcpServer start sucess...");
while (true) {
// 获取连接
struct sockaddr_in local;
memset(&local , 0 , sizeof(local));
socklen_t len = sizeof(local);
int sockfd = accept(listen_sockfd_ , (struct sockaddr*)&local , &len);
if (sockfd < 0) {
lg(WARNING , "accept error , error message: %s" , strerror(errno));
continue;
}
// 获取来自客户端的套接字信息
uint16_t client_port = ntohs(local.sin_port);
char ipstr[32];
inet_ntop(AF_INET , &(local.sin_addr) , ipstr , sizeof(ipstr));
lg(INFO , "accept sucess , sockfd :%d , client ip :%s , client port :%d" ,
sockfd , ipstr , client_port);
}
}
private:
int listen_sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
这里使用了ntohs
函数来对客户端的端口号进行网络字节序转主机字节序的操作 , 使用了inet_ntop
函数对客户端的IP地址进行网络字节序转主机字节序操作与四字节转点分十进制的操作 , 使用inet_ntop
而不使用inet_ntoa
的原因其中一点是因为inet_ntoa
并不是线程安全的;
-
inet_ntop
函数原型如下:cppconst char *inet_ntop(int af , const void *src , char *dst , socklen_t size);
这个函数调用成功后将会返回一个指向
dst
的指针 , 如果调用失败则会返回空指针;-
af
这个参数表示需要传入一个协议家族 ,
IPv4
传入AF_INET
; -
src
这个参数表示传入需要进行转换的IP地址指针;
-
dst
这个参数是一个输出型参数 , 表示在使用这个函数时首先需要有一个缓冲区用来接收转化后的IP地址;
缓冲区可以使用
char
类型的数组充当 , 大小为16
字节即可 , 这里为了保险起见开了32
字节; -
size
这个参数表示需要传入缓冲区的大小 , 使用
sizeof(缓冲区)
即可;
-
这里可以再进行一个测试 , 将获取到并且转换完毕的客户端套接字信息打印出来;
这里直接使用了环回地址进行测试 , 打印结果与预期相同;
当测试完毕后就可以考虑如何处理来自客户端的数据;
之前就已经说过了 , TCP是面向字节流的 , 当创建一个TCP套接字时会返回一个TCP套接字的描述符 , 这个描述符占用文件描述符 , 换种说法就是可以直接使用read
和write
在建立连接的前提下读写这个网络文件与对端进行网络通信(类似于匿名管道);
-
read
cppssize_t read(int fd , void *buf , size_t count);
这个函数功能为从一个文件描述符对应的文件(或是网络文件 , 如TCP)中读取数据并存储在用户定义的缓冲区中;
这个函数调用成功时将返回一个非负整数 , 其中返回正数表示读取到的数据的字节数 , 返回
0
则表示读到了文件结束或是文件被关闭 , 在进行网络编程中 , 例如从TCP套接字描述符中读取到0
时为对端已经关闭 , 因为TCP套接字是面向连接的 , 类似于文件被关闭;-
fd
表示需要传入的文件描述符 , 这里为网络通信 , TCP套接字描述符同样占用文件描述符 , 在进行TCP网络通信的时候传入TCP套接字描述符即可;
-
buf
表示传入一段用户定义的缓冲区中 , 在网络编程中存储的为对端发来的数据;
-
count
表示传入需要读写的大小 , 这个大小根据缓冲区而定 , 可以说这个参数传入的是缓冲区的大小;
-
-
write
cppssize_t write(int fd , const void *buf , size_t count);
这个函数的功能为向一个文件描述符对应的文件(或是网络文件 , 如TCP)中写入数据;
函数调用成功时返回一个非负整数表示写入数据的字节数 , 返回
0
时表示这个函数什么都没有写 , 调用失败时将返回-1
并设置errno
错误码;-
fd
这个参数表示传入一个需要写入的文件描述符 , 在网络编程中这里需要填入的是对应TCP套接字的描述符;
-
buf
表示传入需要写入的数据;
-
count
表示需要写入数据的大小;
-
同时在使用TCP套接字进行网络通信时 , read
和write
中的数据不需要显式进行主机序列转网络序列或是网络序列转主机序列 , 操作系统将会自动进行数据的序列转换;
这里服务端的处理客户端请求的操作实现为一个简单的回响功能 , 这个回响功能为当服务端接收到客户端的数据时将对客户端发来的信息进行简单字符串处理 , 在字符串处理结束后发回给客户端;
cpp
Log lg;
enum { SOCKET_ERR = 2 , BIND_ERR , LISTEN_ERR };
const int backlog = 5;
class TcpServer {
public:
void Server(int sockfd , const std::string& ip , const uint16_t port) {
char buff[4096];
while (true) {
int n = read(sockfd , buff , sizeof(buff)); // 读取
if (n > 0) {
buff[n] = 0;
// 模拟处理请求
std::cout << "Client send a massage@ " << buff << std::endl;
std::string ret("Server get a massage# ");
ret += buff;
write(sockfd , ret.c_str() , ret.size()); // 发回客户端
} else if (n == 0) { // 读取为0时表示
lg(INFO , "Client %s:%d quit... " , ip.c_str() , port);
break;
} else {
lg(WARNING , "read error , error massage: %s" , strerror(errno));
}
}
}
void Start() {
// 获取连接 处理客户端请求
lg(INFO , "TcpServer start sucess...");
while (true) {
// 获取连接
struct sockaddr_in local;
memset(&local , 0 , sizeof(local));
socklen_t len = sizeof(local);
int sockfd = accept(listen_sockfd_ , (struct sockaddr*)&local , &len);
if (sockfd < 0) {
lg(WARNING , "accept error , error message: %s" , strerror(errno));
continue;
}
uint16_t client_port = ntohs(local.sin_port);
char ipstr[32];
inet_ntop(AF_INET , &(local.sin_addr) , ipstr , sizeof(ipstr));
lg(INFO , "accept sucess , sockfd :%d , client ip :%s , client port :%d" ,
sockfd , ipstr , client_port);
// 处理请求
Server(sockfd , ipstr , client_port);
close(sockfd);
}
}
private:
int listen_sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
#endif
这里把回响功能单独封装为一个函数 , 这个函数无限循环调用读取客户端发来的信息打印后进行处理最后再发回给客户端;
其中这里在调用read
的时候可能会出现三种情况 , 一个是正确读取到了数据 , 那么在这种情况下会把读取到的数据当做是字符串来看待;
如果读取到了0
那么显然表示对端关闭了客户端 , 从而导致未维持连接 , 读取到负数则表示读取失败;
这里的对端关闭客户端与读取失败都不能退出程序而是跳出循环即可 , 原因是服务端是长时间为客户端提供服务的 , 当服务端对一个客户端的操作失误时只是结束了对当前客户端的服务 , 但服务端可能在此时也在对其他客户端提供服务 , 不能出现一个客户端的失误导致所有客户端都发生错误从而终止服务 , 不仅如此 , 在一个读取操作或者客户端退出后服务端仍需要监听可能来自其他客户端的连接与请求并随时进行响应;
这里对服务端再次进行测试 , 确保服务端能够正常使用 , 同样的运行服务端并使用telnet
工具来测试服务端是否能够正常运行;
当使用telnet
工具连接到服务端的时候服务端按照正常情况打印信息 , 包括接收到客户端套接字的描述符 , IP
地址与端口号 , 在使用telnet
进行写入请求操作时需要先按下Ctrl + ]
, 出现telnet>
时再按一下回车 , 向服务端写入信息时服务端顺利接收打印同时回响给telnet
充当的客户端 , telnet
再次按下Ctrl + ]
并输入q
或quit
退出连接 , 当客户端退出连接时对应的服务端也结束了对该客户端的服务 , 但并没有终止程序 , 因为当一个客户端退出时服务端仍需要随时监听可能来自其他客户端的连接与请求;
当服务端结束对客户端的服务后需要将对应的套接字描述符关闭以防止服务端的文件描述符被过多占用;
客户端整体结构
同样的将客户端封装为一个类 , 客户端需要向服务端发起连接 , 这意味着客户端同样需要服务端的IP
地址与端口号 , 一般服务端的IP
地址和端口号是专门暴露给客户端用于客户端的连接以及请求的发起的;
cpp
/* tcpclient.cc */
class TcpClient {
public:
TcpClient(const std::string& ip , int16_t port)
: sockfd_(-1) , ip_(ip) , port_(port) {}
void Init() {
// 创建TCP套接字等操作
}
void Start() {
// 对服务端发起连接 请求 接收服务端的响应
}
~TcpClient() {
// 关闭无用的TCP套接字描述符
if(sockfd_ != -1)
close(sockfd_);
}
private:
int sockfd_; // 套接字描述符
std::string ip_; // 用户传递的IP地址
int16_t port_; // 用户传入的端口号
};
客户端类成员如下:
-
构造函数
构造函数主要用来初始化这个类 , 主要需要进行初始化的是需要通信的服务端的
IP
与端口号;套接字描述符默认初始化一个无效的值
-1
; -
析构函数
这里的析构函数主要是用来关闭客户端创建的TCP套接字;
-
Init()
函数这个函数主要用来初始化客户端需要通信的网络资源 , 最主要的资源是客户端需要一个属于自己的TCP套接字用来与服务端进行通信;
-
Start()
函数这个函数主要是客户端向服务端发起连接 , 发起请求以及获取服务端的响应;
-
sockfd_
用来存储客户端的套接字描述符 , 初始化为
-1
表示为一个无效值; -
ip_
用来存储用户需要建立连接的相应的服务端的
IP
; -
port_
用来存储用于需要建立连接的相应的服务端的端口号;
客户端整体的结构除了这个类以外还有一个用来实例化客户端实例的主函数main
函数 , 是客户端的启动函数;
cpp
/* client.cc */
void Usage() { printf("\n\tUsage : ./client ip port[port>1024]\n\n"); }
int main(int argc , char* argv[]) {
if (argc != 3) {
Usage();
exit(-1);
}
// 获取用户输入的IP与端口号
int16_t port = std::stoi(argv[2]);
std::string ip(argv[1]);
// 实例化客户端对象
TcpClient tc(ip , port);
tc.Init();
tc.Start();
return 0;
}
这个文件主要包含客户端的启动函数main
函数 , 同时这个文件还包含了一个Usage
函数提示用户使用客户端时的参数;
bash
./client ip port[port>1024]
表示需要传入服务端的IP
地址和端口号;
当参数匹配时对应的IP
地址与端口号会被接收并在实例化客户端的时候初始化客户端实例;
调用客户端实例的Init()
函数用于准备网络通信所需资源 , 包括TCP套接字的创建等 , 再调用客户端实例的Start()
函数用于向服务端发起连接 , 请求等操作;
对应的服务端的IP
和端口号必然是固定的 , 每一个客户端都将需要通过服务端的这个IP
和端口号对服务端发起连接;
客户端的套接字创建
同样的客户端需要一个TCP套接字用来与服务端建立连接;
和UDP套接字相同的是客户端不需要显式的bind
绑定自己的端口号 , 客户端的绑定操作会在其向服务端发起连接的时候随机选择一个端口号进行;
同样的客户端也不需要进行监听操作 , 只有服务端需要进行监听操作 , 服务端需要进行监听的原因是将会存在多个客户端随时向服务端发起连接 , 为了保证服务端能够随时为客户端提供服务 , 服务端必须一直保持监听状态来处理发起连接的客户端 , 这些操作都是由客户端主动向服务端发起连接的 , 而并没有服务端会去主动向客户端发起连接 , 相同的客户端不会去主动向其他客户端发起连接;
所以实际上客户端最主要的操作就是TCP套接字的创建;
cpp
class TcpClient {
public:
TcpClient(const std::string& ip , int16_t port)
: sockfd_(-1) , ip_(ip) , port_(port) {}
void Init() {
// 创建TCP套接字
sockfd_ = socket(AF_INET , SOCK_STREAM , 0);
if (sockfd_ < 0) {
printf("socket error , error message: %s\n" , strerror(errno));
exit(2);
}
}
private:
int sockfd_; // 套接字描述符
std::string ip_; // 用户传递的IP地址
int16_t port_; // 用户传入的端口号
};
客户端向服务端发起连接
客户端需要主动向服务端发起连接 , 只有客户端向服务端发起连接 , 服务端监听到客户端的连接并且接收到客户端的连接 , 即客户端与服务端建立联系后客户端与服务端才能具备通信的条件;
客户端不需要绑定也不需要监听 , 当客户端的套接字创建好后就可以向服务端发起连接;
客户端使用connect
函数向服务端发起连接;
-
connect()
函数cppint connect(int sockfd , const struct sockaddr *addr , socklen_t addrlen);
这个函数的功能是向一个处于监听状态的套接字发起连接;
这个函数调用成功时返回
0
, 调用失败时返回-1
并设置一个错误码errno
;-
sockfd
这个参数表示发起连接的套接字描述符 , 即客户端自身的套接字描述符;
-
* addr
这个参数表示传入一个
sockaddr
结构体 , 这个sockaddr
结构体中包含了与服务端相关的参数 , 包括服务端的IP
地址 , 端口号 , 网络协议类型等; -
addrlen
这个参数表示传入
sockaddr
结构体的大小;
-
当客户端调用connect
函数向服务端发起连接时将会自动绑定一个端口号;
cpp
class TcpClient {
public:
void Start() {
// 发起连接
struct sockaddr_in server;
memset(&server , 0 , sizeof(server));
server.sin_family = AF_INET;
inet_pton(AF_INET , ip_.c_str() , &(server.sin_addr));
server.sin_port = htons(port_);
socklen_t len = sizeof(server);
if (connect(sockfd_ , (sockaddr*)&server , len)) {
printf("connect error , error message: %s\n" , strerror(errno));
exit(3);
}
}
private:
int sockfd_; // 套接字描述符
std::string ip_; // 用户传递的IP地址
int16_t port_; // 用户传入的端口号
};
客户端在向服务端发起连接时必须知道服务端的IP
地址与端口号 , 否则客户端无法知道需要将连接发送给谁;
同样的所传入的结构体信息需要经过网络通信发送给服务端 , 所以如IP
地址和端口号一样是需要从主机字节序转网络字节序 , 这里同样调用了inet_pton
用来进行IP
地址点分十进制转四字节 , 主机字节序转网络字节序 , 调用htons
将端口号由主机字节序转网络字节序;
当connect
函数调用成功时表示双端之间能够建立网络通信 , 如果调用失败则表示连接没有发送成功或是其他原因导致的失败 , 这表明服务端与客户端仍不具备网络通信的条件 , 此时就应该打印错误信息并退出程序或是进行一些错误处理操作;
客户端向服务端发起请求与接收响应
当客户端向服务端发起连接 , 并且这个连接被服务端监听并获取 , 也就是说客户端与服务端建立起连接时才表示客户端与服务端能够进行网络通信;
服务端的功能是一个回响功能 , 也就是客户端向服务端发送数据 , 服务端再将这段信息返回给客户端进行一个回响;
这个回响功能表示客户端需要存在两种功能 , 一个是将数据发送给服务端进行请求的发起 , 一个是从服务端中获取数据;
与服务端相同 , 当客户端与服务端建立联系后可以直接调用read
与write
函数对对应的通信套接字描述符像文件读写一样进行读写操作;
请求的发起一定是一个循环操作 , 一个客户端启动之后循环调用getline
函数从标准输入流中获取数据 , 并将这个数据调用write
作为请求发送给服务端 , 当服务端响应过后再调用read
函数将服务端的响应进行接收再进行打印完成一次回响;
cpp
class TcpClient {
public:
void Request() {
std::string request;
char buff[4096];
while (true) {
// 发起请求
std::cout << "Please Enter# ";
std::getline(std::cin , request);
write(sockfd_ , request.c_str() , request.size());
// 接收响应
int n = read(sockfd_ , buff , sizeof(buff));
if (n > 0) {
buff[n] = 0;
std::cout << buff << std::endl;
} else if (n == 0) {
std::cout << "Server exit...." << std::endl;
break;
} else {
printf("Client read error , error message: %s\n" , strerror(errno));
break;
}
}
}
void Start() {
// 对服务端发起连接 请求 以及接收服务端的响应
// 发起连接
struct sockaddr_in server;
memset(&server , 0 , sizeof(server));
server.sin_family = AF_INET;
inet_pton(AF_INET , ip_.c_str() , &(server.sin_addr));
server.sin_port = htons(port_);
socklen_t len = sizeof(server);
if (connect(sockfd_ , (sockaddr*)&server , len)) {
printf("connect error , error message: %s\n" , strerror(errno));
exit(3);
}
// 发起请求与接收响应
Request();
}
private:
int sockfd_; // 套接字描述符
std::string ip_; // 用户传递的IP地址
int16_t port_; // 用户传入的端口号
};
这里将发起请求和接收响应封装为一个函数为Request
, 循环进行以下操作:
打印输入提示符 , 调用getline
函数从标准输入流中获取数据 , 并调用write
将数据写入至打开的TCP套接字中 , 当服务端接收到请求后会处理请求并将响应发回给客户端 , 客户端的请求函数调用read
读取TCP套接字描述符中的数据来接收服务端的响应并存到缓冲区buff
中 , 这里的read
函数的调用存在三种情况 , 当返回值>0
时表示读取成功 , 此时对应的响应需要当做一个字符串来看待 , buff[n]
的位置设置为0
表示字符串末尾 , 当返回值==0
时表示服务端关闭 , 当服务端关闭时为防止客户端向一个不存在的文件描述符写入错误 , 所以读取返回值为0
时客户端需要退出 , 当返回值<0
则表示在调用read
函数时调用失败 , 此时打印错误信息并退出程序;
TCP套接字网络通信单执行流回响程序测试
当服务端和客户端都编写完成后可以对程序进行测试;
运行服务端程序后使用netstat -nltp
查看对应的网络信息 , 服务端被启动 , 绑定的IP
地址为0.0.0.0
表示接受来自该主机中的任意网络接口卡的数据 , 绑定的端口为8888
, 状态为LISTEN
监听状态 , 表示服务端启动成功;
启动一个客户端使用环回地址进行测试(这里的端口变化可以忽略 , 单纯换了个端口);
当客户端成功启动后将会向服务端发起连接 , 服务端监听到连接后将会获取这个连接 , 当服务端监听到这个连接并获取这个连接后表示服务端和客户端建立起了网络通信的条件 , 其中客户端向服务端发起连接的时候将会随机一个端口号进行bind
绑定 , 这里客户端绑定的端口是34850
, 当客户端向服务端发送一些数据作为请求时服务端将会接受这个请求并处理字符串作为响应返回给客户端 , 客户端再次获取这些响应进行一个打印完成一次回响;
程序的结果与预期相同 , 表示服务端与客户端的网络通信没有问题;
但是这个TCP回响程序是一个单执行流的 , 当服务端正在调用Server
函数 , 即处理客户端的请求时将无法进行其他操作 , 如监听来自其他客户端的连接等操作;
黄色区域为第一个客户端所发的信息 , 红色区域为第一个客户端退出时的信息 , 蓝色区域为第二个客户端所发的信息 , 当服务端与第一个客户端建立连接的时候服务端将会持续处理第一个客户端的请求 , 在处理第一个客户端的请求时第二个客户端进来时会向服务端发起连接 , 第二个客户端并没有发出报错表示第二个客户端发出的连接是成功的 , 但是由于服务端只有一个执行流 , 服务端在处理第一个客户端的时候会暂时将第二个客户端存放在自己维护的连接队列中 , 所以此时第二个客户端无论是发起连接还是发送请求服务端都不会进行响应与处理 , 当第一个客户端被关闭时服务端停止对第一个客户端的服务 , 将第二个客户端从连接队列中取出并为第二个客户端提供服务;
-
第一个客户端
服务端正在与第一个客户端通信并处理其请求;
-
第二个客户端
第二个客户端成功发起连接 , 但它必须等到服务端停止对第一个客户端的服务后才能被处理;
在单执行流情况下 , 服务端维持一个连接队列 , 对处于队列中的连接 , 只有当前处理完成后 , 才会取出下一个待处理连接;
解决服务端出现的单执行流的阻塞问题最直接的解决办法就是将单执行流替换为多执行流;
多执行流有两种:
- 多进程
- 多线程
在多执行流的情况下可以保证服务端存在两个执行流,一个执行流用于监听来自客户端的连接,一个执行流用于处理来自客户端的请求并返回响应等操作;
多进程TCP套接字回响程序
利用多进程的方式控制服务端的操作,每当服务端获取到了一个客户端的连接时创建子进程,让子进程代替父进程响应客户端的请求,在父进程fork()
之后可以不再对客户端的连接与请求进行管理,而是去监听下一个可能由其他客户端发来的请求;
利用这种方式可以控制服务端每次都能监听/处理多个来自客户端的连接与请求;
当父进程调用accept
函数获取到客户端的连接时会返回一个TCP套接字描述符,这个TCP套接字描述符会占用文件描述符表,而子进程会继承父进程的资源属性,会继承父进程的文件描述符表;
当子进程继承了父进程的文件描述符表时表示对应的服务端监听套接字与服务端获取客户端连接返回的套接字都被父子两个进程占用,而父进程将获取监听返回的套接字交给子进程后就不需要再对这个通信套接字描述符进行管理,可以将其取消关联;
而子进程只需要管理服务端获取客户端连接返回的套接字描述符,即通信套接字而不需要管理服务端监听套接字,对应的子进程也要对监听套接字描述符进行去关联,这里的去关联操作是因为文件描述符中存在一个引用计数,当引用计数为0
时,即没有进程与这个文件相关联时这个文件将会自动关闭,否则当多个进程指向同一个文件描述符指向的文件时,一个进程退出时将会带走属于自己的文件描述符,即文件描述符中的引用计数会-1
,但由于这个文件描述符被多个进程指向,即使-1
后这个引用计数仍然会>0
,文件无法正常关闭,相应地服务端可用的文件描述符也会越来越少;
当父进程创建了子进程后需要在子进程结束之后对子进程进行回收资源,回收资源的方式有两种,一种为阻塞式等待,一种为非阻塞式等待;
-
阻塞式等待
当父进程阻塞式等待子进程时,又会变回最初的单执行流相同的问题,由于父进程在阻塞式等待子进程,说明父进程在阻塞式等待子进程的时候可能无法去监听来自其他客户端的连接,同样的造成服务端只能为一个客户端提供服务,这里的服务是两种,子进程要为当前连接的客户端处理它的请求,而父进程需要等待为客户端提供服务的子进程,回收其资源;
-
非阻塞式等待
非阻塞式等待必然是一个循环操作,因为他需要循环检查当前是否存在已经编程僵尸进程的子进程,可以解决阻塞式等待造成的同样的单执行流相应的阻塞问题,但父进程等待子进程必定要管理子进程的资源,实际上也会因为管理子进程的PID而占用一定的系统资源;
无论是阻塞式等待还是非阻塞式等待都不是特别推荐,因为子进程帮助父进程执行处理客户端相应的动作,当父进程将这个任务交由给子进程时可以不需要对后续的操作进行管理,这个操作包括不需要知道子进程执行这些任务的结果,所以为了提高父进程的效率,需要无视掉子进程的操作动作,有两种方式可以无视子进程执行任务时后的结果;
-
使用信号忽略
父进程等待子进程不是单纯的等待,准确来说当子进程成为僵尸进程时将会发送
SIGCHLD
信号,让正在等待的父进程知道子进程已经成为了僵尸进程,需要被进行资源回收,而父进程只需要在创建子进程前捕获SIGCHLD
信号并设置为忽略后,当子进程结束后向父进程发送SIGCHLD
信号父进程将会忽略这个信号,当这个信号被设置为忽略状态时父进程将不会把结束了的子进程变成僵尸进程维护在自己的维护队列中,子进程在进程结束后将不会成为僵尸进程,而是直接退出; -
让子进程成为孤儿进程
当一个进程变成孤儿进程后这个进程将不会被原先的父进程管理,其管理权将会交由
Init
操作系统进程,当这个孤儿进程结束后资源回收将会由操作系统进行;
忽略SIGCHLD信号
忽略SIGCHLD
信号的操作需要调用signal
函数;
cpp
sighandler_t signal(int signum, sighandler_t handler);
-
signum
表示传入一个需要进行处理的信号;
-
handler
表示需要对这个信号进行的操作,当需要对信号进行忽略则传递
SIG_IGN
作为参数;
cpp
class TcpServer {
public:
void Server(int sockfd, const std::string& ip, const uint16_t port) {
char buff[4096];
while (true) {
int n = read(sockfd, buff, sizeof(buff));
if (n > 0) {
buff[n] = 0;
std::cout << "Client send a message@ " << buff << std::endl;
std::string ret("Server get a message# ");
ret += buff;
write(sockfd, ret.c_str(), ret.size()); // 发回客户端
} else if (n == 0) {
lg(INFO, "Client %s:%d quit... ", ip.c_str(), port);
break;
} else {
lg(WARNING, "read error , error message: %s", strerror(errno));
}
}
}
void Start() {
// 获取连接 处理客户端请求
signal(SIGCHLD, SIG_IGN); // 忽略 SIGCHLD 信号
lg(INFO, "TcpServer start sucess...");
while (true) {
// 获取连接
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
socklen_t len = sizeof(local);
int sockfd = accept(listen_sockfd_, (struct sockaddr*)&local, &len);
if (sockfd < 0) {
lg(WARNING, "accept error , error message: %s", strerror(errno));
continue;
}
uint16_t client_port = ntohs(local.sin_port);
char ipstr[32];
inet_ntop(AF_INET, &(local.sin_addr), ipstr, sizeof(ipstr));
lg(INFO, "accept sucess , sockfd :%d ,client ip :%s ,client port :%d",
sockfd, ipstr, client_port);
// 创建子进程
pid_t id = fork();
if(id == 0){
// child
close(listen_sockfd_); // 关闭不需要的文件描述符
Server(sockfd,ipstr,client_port);
close(sockfd);
exit(0);
}
else if(id<0){
// error
lg(WARNING,"fork error, error messgae: %s",strerror(errno));
continue;
}
else{
// parent
close(sockfd);
}
}
}
~TcpServer() {
// 清理操作
if (listen_sockfd_ != -1) {
close(listen_sockfd_);
listen_sockfd_ = -1;
}
}
private:
int listen_sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
在Start
函数调用时先调用signal
函数忽略掉SIGCHLD
信号,确保父进程会忽略子进程的SIGCHLD
信号;
当服务端调用accept
函数成功获取到连接后将会返回TCP套接字文件描述符,拿到描述符后创建子进程,父进程关闭不需要的TCP通信套接字描述符,子进程关闭不需要的TCP监听套接字描述符,然后子进程执行Server
函数提供服务,父进程在关闭不需要的描述符后会继续监听下一个来自其他客户端的连接;
当运行服务端并启动多个客户端后客户端的连接与请求不会被服务端以阻塞的方式处理,这里调用了:
cpp
$ ps axj | head -1 && ps axj | grep ./server | grep -v grep | grep -v vscode
来查看当前server
服务端的进程,有几个客户端连接这个服务端时服务端将会有n+1
个进程,其中一个进程为服务端的父进程,这里使用ps
时屏蔽掉了vscode
和grep
关键字让显示出的进程结果更有可读性;
让子进程变成孤儿进程
如果光是按照字面意思,把子进程变为孤儿进程是一个较为复杂的操作,如果需要把子进程变为孤儿进程的话需要在服务端fork
创建出子进程后使父进程退出,当父进程退出之后子进程将会变成孤儿进程从而交由操作系统管理,但是父进程在退出之后为了确保服务端继续为其他客户端提供监听服务需要重新再启动,但如果使用这种方式的话负责监听的父进程可能会遗漏某些客户端的连接,使得客户端需要等待一定时间,同时父进程需要来回进行退出和重新启动,会大大增加操作系统的负担;
最直接有效的方式是服务端调用accept
函数获取到客户端连接的时候创建子进程,当子进程创建成功之后再次调用fork
函数再创建一个进程,当第二次fork
调用成功,也就是服务端的子进程的子进程,也就是服务端的孙子进程创建成功后立马将中间进程退出,由于服务端的孙子进程是由服务端的子进程管理的,当服务端的孙子进程创建成功,服务端的子进程退出后其孙子进程将变成孤儿进程进而被操作系统管理;
cpp
class TcpServer {
public:
void Server(int sockfd, const std::string& ip, const uint16_t port) {
char buff[4096];
while (true) {
int n = read(sockfd, buff, sizeof(buff));
if (n > 0) {
buff[n] = 0;
std::cout << "Client send a message@ " << buff << std::endl;
std::string ret("Server get a message# ");
ret += buff;
write(sockfd, ret.c_str(), ret.size()); // 发回客户端
} else if (n == 0) {
lg(INFO, "Client %s:%d quit... ", ip.c_str(), port);
break;
} else {
lg(WARNING, "read error , error message: %s", strerror(errno));
}
}
}
void Start() {
lg(INFO, "TcpServer start sucess...");
while (true) {
// 获取连接
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
socklen_t len = sizeof(local);
int sockfd = accept(listen_sockfd_, (struct sockaddr*)&local, &len);
if (sockfd < 0) {
lg(WARNING, "accept error , error message: %s", strerror(errno));
continue;
}
uint16_t client_port = ntohs(local.sin_port);
char ipstr[32];
inet_ntop(AF_INET, &(local.sin_addr), ipstr, sizeof(ipstr));
lg(INFO, "accept sucess , sockfd :%d ,client ip :%s ,client port :%d",sockfd, ipstr, client_port);
pid_t id = fork(); // 第一次 fork
if(id == 0){
// 中间进程
if(fork() == 0){ // 第二次fork
// 孙子进程
close(listen_sockfd_);
Server(sockfd, ipstr, client_port);
close(sockfd);
exit(0);
}
exit(0);
}
else if(id<0){
// error
lg(WARNING,"fork error, error messgae: %s",strerror(errno));
continue;
}
else{
// parent
close(sockfd);
}
}
}
private:
int listen_sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
这里服务端获取到父进程的连接后进行第一次fork
,父进程负责关闭不需要的套接字描述符随后继续进行监听操作;
子进程被创建成功之后再次fork
创建出孙子进程,当孙子进程创建成功后子进程也就是中间进程调用exit
退出,将孙子进程变成孤儿进程交由操作系统管理;
服务端不会阻塞着只为一个客户端服务,同时由于父进程不需要为子进程提供等待服务,只需要进行fork
创建进程后就可以继续监听来自其他客户端的连接从而提高了效率,其中三个进程的PPID
都为1
,说明这三个进程已经交由操作系统进行管理,当这三个进程结束时操作系统将自行回收这些资源;
这种操作被称之为双重fork
,使用双重fork
可以避免僵尸进程,也可以减少父进程的干预;
-
避免僵尸进程
任何直接由父进程托管生命周期的工作任务可以交由操作系统更高效的进行资源管理;
-
减少父进程干预
父进程不必持续链接并监视这些工作任务,使得父进程能够集中精力处理入站请求,新连接等活动而不必担心资源泄漏或过渡管理产生的负担;
多线程TCP套接字回响程序
实际上多进程只能满足一些微型应用,因为当计算机中出现过多的进程将会大大增加操作系统的负担,同时若是进程数量大于该系统的限制数时操作系统将会杀掉这些进程以保持操作系统自身的运行,不仅如此,创建一个进程的开销是很大的,创建一个进程时需要为这个进程维护它的PCB结构体,进程地址空间,页表等结构;
但是如果只是创建线程的话将会大大减少开销,因为线程只需要为线程开辟栈和对应的TCB结构体即可,线程是比进程更细的执行流,属于是进程中的一部分,同一个进程内的多个线程共享同一张文件描述符表;
多线程的版本只需要当服务端的主线程调用accept
函数成功获取对应客户端的连接后创建一个新的线程来处理响应客户端的请求;
多线程的版本不需要像多进程版本那样需要再次创建一个线程来进行工作,线程是可以进行detach
分离操作的,当新线程调用detach
函数后将会分离,分离后在主线程不提前退出的前提下新线程执行完自己的任务后会自行退出并且进行资源释放,不需要主线程调用join
进行等待;
创建线程时需要调用pthread_create
函数;
cpp
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
-
pthread_t *thread
这个参数是一个输出型参数,用于保存创建新线程的线程
tid
,本质上pthread_t
类型是一个无符号长整形unsigned long int
的typedef
; -
const pthread_attr_t *attr
这个参数指向线程属性结构的指针,传入
nullptr
使用默认属性; -
void *(*start_routine) (void *)
这个类型是一个函数指针,函数指针表示传入一个函数作为线程的入口点,这个函数必须是返回值和参数类型都属于
void*
的; -
void *arg
表示传递一个参数,这里的
void*
表示参数可以进行强制类型转换,参数可以是任何类型;
无论是单进程,多进程,多线程的服务端,服务端在为客户端提供服务时本质上是调用Server
函数进行的,而在这个程序中设计的Server
函数原型为:
cpp
void Server(int sockfd, const std::string& ip, const uint16_t port) ;
一共有三个参数,这说明线程的执行函数中的void*
并不能满足传递多个参数的条件,所以线程在使用多参的情况下一定要使用一个结构来存储多个参数,这里可以使用一个类来进行封装,封装这些Server
函数需要的成员对象为一个整体后将这个类对象的指针传递给新线程来供服务端的新线程处理客户端的请求操作;
不仅如此,新线程的函数可以作为服务端类内的成员函数,也可以作为类外的函数,但是毕竟这个执行函数只为服务端中的线程提供执行服务,所以没必要把这个函数设计到类外以防止用户误调用,但是如果设计到类内的话这个函数的参数必定会存在一个隐含的this
指针,导致线程的执行函数参数不匹配,所以需要把这个函数设计为static
静态成员函数,但是由于没有了this
指针,这个函数内部无法直接调用服务端类内部的Server
函数,但放到类外一样无法直接调用,所以无论如何都必须将服务端类的this
指针传递给线程执行函数,所以存储参数的类一共需要四个参数;
cpp
class thread_data {
public:
thread_data(TcpServer* ts, int sockfd, const std::string& ip,
const uint16_t &port)
: serverthis_(ts),
data_sockfd_(sockfd),
data_ip_(ip),
data_port_(port) {}
TcpServer* serverthis_;
int data_sockfd_;
std::string data_ip_;
uint16_t data_port_;
};
这里把thread_data
设计成了一个内部类,原因是它是专门为TcpServer
服务的,并且不会被其他类或模块使用;
同时多线程与多进程不同,多进程中子进程将继承父进程的文件描述符表,但每个进程的文件描述符表是相互独立的,这意味着子进程与父进程进行任务区分后将可能存在不需要的文件描述符,而线程不同,线程属于进程内部的一个执行流,这里的线程是属于服务端进程内部的,同一个进程内的多个线程共享同一个文件描述符,所以在多线程中不能随意关闭文件描述符,否则必然会导致崩溃或其他问题;
cpp
class TcpServer {
public:
static void* Routine(void* args) {
pthread_detach(pthread_self()); // 分离线程
struct thread_data* data = static_cast<struct thread_data*>(args);
data->serverthis_->Server(data->data_sockfd_, data->data_ip_,
data->data_port_);
delete data;
return nullptr;
}
void Start() {
lg(INFO, "TcpServer start sucess...");
while (true) {
// 获取连接
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
socklen_t len = sizeof(local);
int sockfd = accept(listen_sockfd_, (struct sockaddr*)&local, &len);
if (sockfd < 0) {
lg(WARNING, "accept error , error message: %s", strerror(errno));
continue;
}
uint16_t client_port = ntohs(local.sin_port);
char ipstr[32];
inet_ntop(AF_INET, &(local.sin_addr), ipstr, sizeof(ipstr));
lg(INFO, "accept sucess , sockfd :%d ,client ip :%s ,client port :%d",
sockfd, ipstr, client_port);
// 创建线程
pthread_t tid;
thread_data* data = new thread_data(this, sockfd, ipstr, client_port);
pthread_create(&tid, nullptr, Routine, data);
}
}
}
private:
int listen_sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
class thread_data {
public:
// 线程数据所需 - 内部类
thread_data(TcpServer* ts, int sockfd, const std::string& ip,
const uint16_t &port)
: serverthis_(ts),
data_sockfd_(sockfd),
data_ip_(ip),
data_port_(port) {}
TcpServer* serverthis_;
int data_sockfd_;
std::string data_ip_;
uint16_t data_port_;
};
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
定义了一个内部类thread_data
进行线程所需的数据存储;
当服务端成功获取到来自客户端的连接时创建线程,同时new
一个thread_data
对象并把线程所需的数据传递给这个对象中,这里使用new
的原因是因为主线程与新线程不在同一个栈帧中,为了确保数据的安全性选择在堆上为他们进行维护,当使用完后再将堆上的这块空间进行清理即可;
线程的工作主要是调用Server
函数来处理来自客户端的请求,并响应回客户端中,当线程使用完这些资源后需要及时进行释放,否则会出现内存泄漏的问题;
测试结果如下:
网络通信正常,没有出现阻塞问题,即服务端可以同时为多个客户端提供服务,这里的文件描述符与多进程版本的程序不同,多进程版本的程序不管多少个客户端与服务端进行连接后对应的套接字描述符都为4
,是因为进程与进程之间存在相互独立的文件描述符表,而同一个进程中线程间共享同一张文件描述符表,所以这里的文件描述符将会逐渐增长,但是这里的文件描述符没有一个是多余的,其都维持着对应的工作,即与其他的客户端维持连接与网络通信;
使用ps -axj
查看进程结果,只存在一个进程,因为这个程序是多线程版本,如果使用ps -aL
的命令来看的话就可以看到多个线程;
其中红色为主线程,绿色为新线程;
线程池版TCP套接字回响程序
使用多线程的方式实现TCP套接字回响程序大大降低了操作系统整体的负载与开销,当服务端监听到一个客户端的连接时将会获取这个连接,获取连接是调用accept
函数进行的,当获取连接成功时会返回一个用于通信的TCP套接字描述符,这个TCP套接字描述符是占用文件描述符表的,但是一个进程的文件描述符表是有限制的,当一个进程的文件描述符被用完后将不会再为新的客户端维护新的线程;
同时一个进程中可创建的线程数也是有限的,一个进程无法创建出超出限制的线程数,如果创建的线程数超过了限制后这个进程将无法创建出新的线程;
所以实际上的网络程序服务端并不会一直为同一个客户端一直提供服务,而是来一个客户端服务一次的方式;
处理上面两个问题的最好的解决办法是服务端取消循环对同一个客户端提供服务,并且采用线程池来实现这个服务端以确保不会创建出超出限制数量的线程,也可以大大降低若干个客户端向服务端发起请求时服务端的负载;
换句话来说就是实际上服务端提供给客户端的服务时,不断有客户端在增多的同时也会存在不断的客户端在退出,这个进程中不断有线程在创建也同时一定会不断有线程在退出,只提供短服务而不提供长服务;
同时这里使用线程池的原因其中之一是因为在多线程中每获取到一个客户端的连接时都会创建一次线程,虽然创建线程的代价和开销要大大小于进程,但始终存在开销,如果使用线程池的话线程池将会预先为服务端预加载一批线程,这些线程将由线程池管理,线程不会因为服务端获取客户端的连接越来越多而变多,也不会因为客户端的退出变少;
-
线程池代码如下
cppstruct ThreadInfo { pthread_t tid; std::string name; }; template <class T> class ThreadPool { // 默认线程池大小 const static int defaultnum = 7; private: // 锁定互斥锁 void Lock() { pthread_mutex_lock(&mutex_); } // 解锁互斥锁 void Unlock() { pthread_mutex_unlock(&mutex_); } // 唤醒等待的线程 void Wake() { pthread_cond_signal(&cond_); } // 等待条件变量 void Wait() { pthread_cond_wait(&cond_, &mutex_); } // 检查任务队列是否为空 bool Isempty() { return tasks_.empty(); } public: // 根据线程ID获取线程名称 std::string Getname(pthread_t tid) { for (const auto &ti : threads_) { if (ti.tid == tid) { return ti.name; } } return "null"; } public: // 启动线程池中的所有线程 void Start() { int num = threads_.size(); for (int i = 0; i < num; ++i) { threads_[i].name = "Thread-" + std::to_string(i); pthread_create(&(threads_[i].tid), nullptr, HanderTask, this); } } // 向任务队列添加任务 void Push(const T &t) { Lock(); tasks_.push(t); Wake(); Unlock(); } // 从任务队列取出任务 T Pop() { T t = tasks_.front(); tasks_.pop(); return t; } // 线程处理任务的静态函数 static void *HanderTask(void *args) { ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args); std::string name = tp->Getname(pthread_self()); while (1) { tp->Lock(); while (tp->Isempty()) { tp->Wait(); } T t = tp->Pop(); tp->Unlock(); // 执行任务并打印结果 t(); } return nullptr; } // 获取线程池单例实例 static ThreadPool<T> *getInstance(int volume = defaultnum) { if (nullptr == Tp_) { std::cout << "The instance load first..." << std::endl; pthread_mutex_lock(&lock_); if (nullptr == Tp_) { Tp_ = new ThreadPool<T>(volume); } pthread_mutex_unlock(&lock_); } return Tp_; } private: // 私有构造函数,实现单例模式 ThreadPool(int volume = defaultnum) : threads_(volume) { pthread_mutex_init(&mutex_, nullptr); pthread_cond_init(&cond_, nullptr); } // 析构函数 ~ThreadPool() { pthread_mutex_destroy(&mutex_); pthread_cond_destroy(&cond_); } private: // 禁用拷贝构造和赋值操作符 const ThreadPool<T> &operator=(const ThreadPool<T> &&) = delete; ThreadPool(const ThreadPool<T> &&) = delete; 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;
这个线程池是一个单例模式的线程池,调用这个单例类中的成员函数使用
ThreadPool<T>::getInstance()->function()
即可,其中ThreadPool<T>::getInstance()
为获取这个线程池的单例;
线程池中的线程主要是用来执行任务,所以相应的需要存在一个任务类;
cpp
class Task {
public:
Task() {} // 默认构造
// 构造函数:初始化所有成员变量
Task(int sockfd, const std::string& clientip, const uint16_t clientport)
: sockfd_(sockfd), clientip_(clientip), clientport_(clientport) {}
// 析构函数(当前为空)
~Task() {}
// 执行任务的主要函数
void run() {
char buff[4096];
int n = read(sockfd_, buff, sizeof(buff));
if (n > 0) {
buff[n] = 0;
std::cout << "Client send a message@ " << buff << std::endl;
std::string ret("Server get a message# ");
ret += buff;
write(sockfd_, ret.c_str(), ret.size()); // 发回客户端
} else if (n == 0) {
lg(INFO, "Client %s:%d quit... ", clientip_.c_str(), clientport_);
} else {
lg(WARNING, "read error , error message: %s", strerror(errno));
}
close(sockfd_);
}
// 重载()运算符,使对象可以像函数一样被调用
void operator()() { run(); }
private:
int sockfd_;
std::string clientip_;
uint16_t clientport_;
};
这里的程序是一个回声程序,所以任务类中主要的操作是用来执行之前Server
函数相应的功能,即客户端向服务端发送请求,服务端接收到请求后响应回客户端,这里任务类的run
函数的功能就是Server
函数的功能,这个任务类有三个成员变量分别为通信套接字的描述符,客户端IP地址以及客户端的端口号,这个任务类重载了operator()
间接调用run
函数使服务端调用线程池执行这个任务;
其中这个run
函数是一个短服务而不是长服务,以确保线程数量不会过多,即使达到峰值也能到一个可控的状态,同时也确保了文件描述符不会超过限制数量;
对应的服务端只需要包含线程池对应的文件,并且加载线程池即可,当服务端获取到客户端的连接后构造一个任务类,这个任务类构造后把任务类存放到线程池中的任务队列中,线程池将会自行处理这些任务,而主进程将会回到监听状态继续监听来自其他客户端的连接与请求;
cpp
class TcpServer {
public:
void Start() {
// 启动线程池单例
ThreadPool<Task>::getInstance()->Start();
lg(INFO, "TcpServer start sucess...");
while (true) {
// 获取连接
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
socklen_t len = sizeof(local);
int sockfd = accept(listen_sockfd_, (struct sockaddr*)&local, &len);
if (sockfd < 0) {
lg(WARNING, "accept error , error message: %s", strerror(errno));
continue;
}
uint16_t client_port = ntohs(local.sin_port);
char ipstr[32];
inet_ntop(AF_INET, &(local.sin_addr), ipstr, sizeof(ipstr));
lg(INFO, "accept sucess , sockfd :%d ,client ip :%s ,client port :%d",
sockfd, ipstr, client_port);
// 构造任务类
Task t(sockfd, ipstr, client_port);
// 把任务Push到线程池中的任务队列中
ThreadPool<Task>::getInstance()->Push(t);
}
}
private:
int listen_sockfd_; // 服务端的监听套接字描述符
uint16_t port_; // 服务端的监听端口
protected:
static const uint16_t defaultport; // 默认端口号
};
const uint16_t TcpServer::defaultport = 8080; // 默认端口号初始化
首先调用ThreadPool<Task>::getInstance()->Start()
来启动线程池单例,当服务端接收到来自客户端的连接后将会构造一个任务,任务中一定会需要用到通信套接字描述符,客户端的IP地址和端口号,这些参数都将成为任务对象所需的参数;
当任务类对象构造完毕之后调用线程池的ThreadPool<Task>::getInstance()->Push(t)
把任务对象传入线程池中的任务队列中,随后服务端的主线程将无需再管理与这次操作的任何细节,只需要去监听下一个来自其他客户端的连接即可;
这里使用telnet
工具进行测试,服务端只提供短服务不提供长服务,telnet
执行了一次之后将会退出验证为短服务而不是长服务,这里文件描述符为4
和5
的原因是第一个客户端还没有结束的时候打开的第二个客户端,所以相应的文件描述符还会向上增长,调用ps -aL
函数看与服务端相关的线程,线程数量始终保持着与线程池设置的数量相同,线程不会因为客户端的增加而增加,也不会因为客户端的退出而减少;