文章目录
- 简单的TCP网络程序
- 服务器:
- 服务端创建套接字
- socket函数
- 服务端绑定
- bind函数
- bzero函数
- 引入命令行参数
- 服务端监听
- listen函数
- 服务端获取连接
- accept函数
- 测试上述的功能
- telnet命令
- 服务端处理请求(提供服务)
- read函数
- write函数
- tcp_server.cc
- 客户端
- 客户端创建套接字
- 引入命令行参数
- 客户端连接服务器
- connect函数
- 客户端发送请求
- tcp_client.cc
- 测试
- 上述单执行流服务器存在的问题
- 多进程版的TCP网络程序
- 等待子进程退出问题
- 方法1-捕捉SIGCHLD信号
- 方法2-让孙子进程提供服务
- 多线程版的TCP网络程序
- 线程池版的TCP网络程序
- 任务类设计
- 关于绑定失败的问题
- 资源未释放干净
- 端口号已被其他程序绑定
- 无法绑定的端口号
- TCP协议通信流程
- TCP 和 UDP 对比
简单的TCP网络程序
服务器:
服务端创建套接字
socket函数
这里创建套接字和Udp那里一致,都是使用socket函数创建套接字
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
第一个参数:协议家族
- 协议家族选择
AF_INET
,因为我们要进行的是网络通信
第二个参数:套接字的类型(服务类型)
- 因为我们编写的是TCP服务器,所以我们要选择 流式套接:
SOCK_STREAM
SOCK_STREAM
提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务
第三个参数:协议类型
- 默认写为0即可
如果创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时也就没必要进行后续操作了,直接终止程序即可
使用例子:
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock < 0)
{
std::cerr <<"socket error: " << errno << std::endl;
return 2;
}
注意事项:
TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务
服务端绑定
套接字创建完毕后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字后我们还需要调用bind函数进行绑定操作
bind函数
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
步骤:
1)定义一个struct sockaddr_in
结构体,将服务器网络相关的属性信息填充到该结构体当中:如:使用的协议家族,IP地址,端口号
- 填充服务器网络相关的属性信息时,协议家族对应就是
AF_INET
,端口号就是当前TCP服务器程序的端口号.在设置端口号时,需要调用htons函数将端口号由主机序列转为网络序列
2)在设置服务器的IP地址时,我们可以设置为本地环回127.0.0.1
,表示本地通信,也可以设置为公网IP地址,表示网络通信,但是如果是云服务器:在设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY
即可
此时服务器就可以从本地任何一张网卡当中读取数据
- 由于
INADDR_ANY
本质就是0,因此在设置时不需要进行网络字节序的转换
3)填充完服务器网络相关的属性信息后,需要调用bind函数进行绑定
**注意事项1:**当定义好struct sockaddr_in
结构体后,最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空
bzero函数
#include <strings.h>
void bzero(void *s, size_t n);
函数作用:
bzero函数也可以对特定的一块内存区域进行清空
引入命令行参数
注意事项2:由于**此时我们需要服务器的端口号,**因此我们可以引入命令行参数,由用户启动服务端的时候填入,由于当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不需要绑定公网IP地址,直接绑定INADDR_ANY
即可,因此命令行参数中没有在引入IP地址
- 所以我们之后是这样启动服务端的:
./tcp_server tcp_port(服务器server要绑定的端口)
所以一共有两个命令行参数,如果使用不正确,我们可以把使用手册进行打印出来:
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
// ./tcp_server tcp_port(服务器server要绑定的端口)
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);//使用手册
return 1;
}
//第1步: 创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock < 0)
{
std::cerr <<"socket error: " << errno << std::endl;
return 2;
}
//第2步:绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));//先把所有的内容初始化为0
//也可以选择用bzero:
//bzero(&local,sizeof(local))
//填充结构体内容
local.sin_family = AF_INET;//协议家族: 选择IPV4
local.sin_port = htons(atoi(argv[1]));//字符串端口号转为整数端口号(主机序) -> 转为网络字节序
local.sin_addr.s_addr = INADDR_ANY;//云服务器不建议绑定固定的IP
//绑定
if(bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "bind error: " << errno << std::endl;
return 3;
}
//.....
}
绑定实际就是将文件与网络关联起来,如果绑定失败也没必要进行后续操作了,直接终止程序即可
注意:TCP服务器绑定时的步骤与UDP服务器是完全一样的,没有任何区别
服务端监听
TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态
- 一定有人主动建立连接(一般是客户端建立->因为它需要服务),一定有人被动接受连接(是服务器,因为它提供服务)
设置监听状态本质是:允许用户连接
(服务器连接上了,但是此时能否通信还是未知的)
listen函数
listen接口的作用是设置套接字为listen监听状态,本质是允许用户进行连接
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
参数说明
- sockfd:需要设置为监听状态的套接字对应的文件描述符
- backlog:全连接队列的最大长度
- 如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列
- 该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可
返回值说明
监听成功返回0,监听失败返回-1,同时错误码会被设置
使用例子:
const int back_log = 5;
if(listen(listen_sock, back_log) < 0)
{
std::cerr << "listen error" << std::endl;
return 4;
}
如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可
注意事项:
1)初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字listen_sock
2)只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成
服务端获取连接
完成了上述的步骤之后,TCP服务器就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求
accept函数
打开监听状态,之后就能获取到客户端的连接请求,accept接口的作用是接收客户端的请求,接收成功就正式建立了连接
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明
-
sockfd:特定的监听套接字,表示从该监听套接字中获取连接
-
addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等
-
addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度
addr和addrlen是一个输入输出型参数,获取链接之后,对端的网络相关信息会被放在addr结构体当中
返回值说明
获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置
那我们就好奇了,accept函数返回的套接字是什么?
1)调用accept函数获取连接时,是从监听套接字当中获取的,如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符
监听套接字与accept函数返回的套接字的作用:
- 监听套接字:用于获取客户端发来的连接请求,accept函数会不断从监听套接字当中获取新连接
- accept函数返回的套接字:用于为本次accept获取到的连接提供服务
监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字
服务端连接的时候注意事项:
1)accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后,应该继续下一轮的获取连接
2)如果要将获取到的连接对应客户端的IP地址和端口号信息进行输出,观察是谁连接了我们的服务端,此时:
需要调用inet_ntoa
函数将整数IP转换成字符串IP,调用ntohs
函数将端口号由网络序列转换成主机序列
inet_ntoa
函数在底层实际做了两个工作- 1.将网络序列转换成主机序列,
- 2.将主机序列的整数IP转换成字符串风格的点分十进制的IP
//对外提供服务
for( ; ; )
{
struct sockaddr_in peer;//记录远端(客户端)的网络的相关属性
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len);//获取客户端谁来访问我
if(new_sock < 0)//获取新链接失败,继续下一次获取
{
continue;
}
//连接成功
//对端的网络相关信息被放到了peer结构体里面
//为了方便打印观察是谁连接的我
uint16_t cli_port = ntohs(peer.sin_port);//客户端的端口->peer.sin_port拿到的是网络字节序->需要用ntohs函数转为主机序
std::string cli_ip = inet_ntoa(peer.sin_addr);//客户端的IP地址->peer.sin_addr拿到的是4字节IP地址->用inet_ntoa转为字符串IP
std::cout << "get a new link -> : [" << cli_ip << ":" << cli_port <<"]# " << new_sock << std::endl;
//...............................
}
测试上述的功能
#include <iostream>
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <strings.h>
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
// ./tcp_server tcp_port(服务器server要绑定的端口)
int main(int argc,char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return 1;
}
//1.创建套接字
int listen_sock = socket(AF_INET,SOCK_STREAM,0);
if(listen_sock<0)
{
std::cerr<<"socket error:"<<errno<<std::endl;
return 2;
}
//2.绑定
struct sockaddr_in local;
memset(&local,0,sizeof(local));
//填充结构体内容
local.sin_family = AF_INET;//协议家族: 选择IPV4
local.sin_port = htons(atoi(argv[1]));//字符串端口号转为整数端口号(主机序) -> 转为网络字节序
local.sin_addr.s_addr = INADDR_ANY;//云服务器不建议绑定固定的IP
//绑定
if(bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "bind error: " << errno << std::endl;
return 3;
}
//监听
const int back_log = 5;
if(listen(listen_sock, back_log) < 0)
{
std::cerr << "listen error" << std::endl;
return 4;
}
//获取连接
//对外提供服务
for( ; ; )
{
struct sockaddr_in peer;//记录远端(客户端)的网络的相关属性
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len);//获取客户端谁来访问我
if(new_sock < 0)//获取新链接失败,继续下一次获取
{
continue;
}
//连接成功
//对端的网络相关信息被放到了peer结构体里面
//为了方便打印观察是谁连接的我
uint16_t cli_port = ntohs(peer.sin_port);//客户端的端口->peer.sin_port拿到的是网络字节序->需要用ntohs函数转为主机序
std::string cli_ip = inet_ntoa(peer.sin_addr);//客户端的IP地址->peer.sin_addr拿到的是4字节IP地址->用inet_ntoa转为字符串IP
std::cout << "get a new link -> : [" << cli_ip << ":" << cli_port <<"]# " << new_sock << std::endl;
//...............................
}
return 0;
}
编译代码后,以./tcp_server 服务器要绑定的端口号
的方式运行服务端
我们可以使用netstat
命令进行观察:
此时看到一个程序名为tcp_server的服务程序,它绑定的端口就是8081,由于服务器绑定的是INADDR_ANY
,因此该服务器的本地IP地址是0.0.0.0
,这就意味着该TCP服务器可以读取本地任何一张网卡里面的数据
此外,最重要的是当前该服务器所处的状态是LISTEN
状态,表明当前服务器可以接收外部的请求连接
telnet命令
此时我们可以使用telnet
命令远程登录到该服务器,因为telnet
底层实际采用的就是TCP协议
可能会出现一下情况:
解决方法:https://blog.csdn.net/weixin_53060366/article/details/124591072
具体步骤是:1.先看看有没有安装telnet客户端和telnet-server服务端
执行下述两个命令,如果执行结果都是空白的话,说明没安装
#rpm -q telnet
#rpm -q telnet-server
步骤2:下载telnet客户端和telnet-server 服务端
yum list telnet* # 列出telnet相关的安装包
yum install telnet-server # 安装telnet服务
sudo yum install telnet.* #安装telnet客户端 -需要管理员权限
此时使用telnet
命令连接当前TCP服务器后可以看到,此时服务器接收到了一个连接
为什么为该连接提供服务的套接字对应的文件描述符就是4
原因:
1)因为0、1、2是默认打开的,其分别对应标准输入流、标准输出流和标准错误流
2)而3号文件描述符分配给了监听套接字,因此当第一个客户端发起连接请求时,为该客户端提供服务的套接字对应的文件描述符就是4
如果此时我们再用其他窗口继续使用telnet
命令,向该TCP服务器发起请求连接,此时为该客户端提供服务的套接字对应的文件描述符就是5
除此之外,也可以直接用浏览器来访问这个TCP服务器,因为浏览器常见的应用层协议是http或https,其底层对应的也是TCP协议,因此浏览器也可以向当前这个TCP服务器发起请求连接
如何访问:
云服务器的主机号:当前服务端的端口号
说明一下:
- 至于这里为什么浏览器一次会向我们的TCP服务器发起两次请求这个问题,这里就不作讨论了,我们只是要证明当前TCP服务器能够正常接收外部的请求连接
服务端处理请求(提供服务)
TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理
但是需要注意的是:此时为客户端提供服务的不是监听套接字listen_sock,而是accept函数返回的套接字,我们可以称之为:服务套接字
- 真正和用户通信用的是accpet返回的新的套接字,listen_sock套接字只负责将新的套接字从底层获取上来
为了让通信双方都能看到对应的现象,所以:
1)服务端在为客户端提供服务时: 将客户端发来的数据进行输出
2)将客户端发来的数据重新发回给客户端即可
当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了
read函数
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示从该文件描述符中读取数据.
- buf:数据的存储位置,表示将读取到的数据存储到该位置
- count:数据的个数,表示从该文件描述符中读取数据的字节数
返回值说明
1)如果返回值大于0,则表示本次实际读取到的字节个数
2)如果返回值等于0,则表示对端已经把连接关闭了
3)如果返回值小于0,则表示读取时遇到了错误
对于第二种情况和第三种情况处理方法都是直接退出服务,此时服务端就不必再为该客户端提供服务了
回忆我们之前管道通信时候的四种情况
- 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪
- 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪
- 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0
- 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取
这里的写端相当于是客户端,读端相当于是服务端
write函数
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
参数说明
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字
- buf:需要写入的数据
- count:需要写入数据的字节个数
返回值说明
写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端
//真正和用户通信用的是accpet返回的新的套接字,listen套接字只负责将新的套接字从底层获取上来
//所以TCP网络通信是文件描述符在不断增多的场景
void ServiceIO(int new_sock) //提供服务的函数
{
//提供服务,我们是一个死循环
while(true)
{
//先从客户端中读取,读完之后再写回给客户端
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
//从new_sock读取,读到buffer,期望读取sizeof(buffer)-1个字节
ssize_t s = read(new_sock, buffer, sizeof(buffer)-1);
if(s > 0) //读取成功
{
buffer[s] = 0; //将获取的内容当成字符串,末尾置\0
std::cout << "client# " << buffer << std::endl;
std::string echo_string = ">>>server<<<, ";
echo_string += buffer;
//把内容重新写回去
write(new_sock, echo_string.c_str(), echo_string.size());
}
else if(s == 0) //对端把连接断了
{
std::cout << "client quit ..." << std::endl;
break;
}
else
{
std::cerr << "read error" << std::endl;
break;
}
}
}
tcp_server.cc
#include <iostream>
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <strings.h>
//真正和用户通信用的是accpet返回的新的套接字,listen套接字只负责将新的套接字从底层获取上来
//所以TCP网络通信是文件描述符在不断增多的场景
void ServiceIO(int new_sock) //提供服务的函数
{
//提供服务,我们是一个死循环
while(true)
{
//先从客户端中读取,读完之后再写回给客户端
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
//从new_sock读取,读到buffer,期望读取sizeof(buffer)-1个字节
ssize_t s = read(new_sock, buffer, sizeof(buffer)-1);
if(s > 0) //读取成功
{
buffer[s] = 0; //将获取的内容当成字符串,末尾置\0
std::cout << "client# " << buffer << std::endl;
std::string echo_string = ">>>server<<<, ";
echo_string += buffer;
//把内容重新写回去
write(new_sock, echo_string.c_str(), echo_string.size());
}
else if(s == 0) //对端把连接断了
{
std::cout << "client quit ..." << std::endl;
break;
}
else
{
std::cerr << "read error" << std::endl;
break;
}
}
}
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
// ./tcp_server tcp_port(服务器server要绑定的端口)
int main(int argc,char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return 1;
}
//1.创建套接字
int listen_sock = socket(AF_INET,SOCK_STREAM,0);
if(listen_sock<0)
{
std::cerr<<"socket error:"<<errno<<std::endl;
return 2;
}
//2.绑定
struct sockaddr_in local;
memset(&local,0,sizeof(local));
//填充结构体内容
local.sin_family = AF_INET;//协议家族: 选择IPV4
local.sin_port = htons(atoi(argv[1]));//字符串端口号转为整数端口号(主机序) -> 转为网络字节序
local.sin_addr.s_addr = INADDR_ANY;//云服务器不建议绑定固定的IP
//绑定
if(bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "bind error: " << errno << std::endl;
return 3;
}
//监听
const int back_log = 5;
if(listen(listen_sock, back_log) < 0)
{
std::cerr << "listen error" << std::endl;
return 4;
}
//获取连接
//对外提供服务
for( ; ; )
{
struct sockaddr_in peer;//记录远端(客户端)的网络的相关属性
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len);//获取客户端谁来访问我
if(new_sock < 0)//获取新链接失败,继续下一次获取
{
continue;
}
//连接成功
//对端的网络相关信息被放到了peer结构体里面
//为了方便打印观察是谁连接的我
uint16_t cli_port = ntohs(peer.sin_port);//客户端的端口->peer.sin_port拿到的是网络字节序->需要用ntohs函数转为主机序
std::string cli_ip = inet_ntoa(peer.sin_addr);//客户端的IP地址->peer.sin_addr拿到的是4字节IP地址->用inet_ntoa转为字符串IP
std::cout << "get a new link -> : [" << cli_ip << ":" << cli_port <<"]# " << new_sock << std::endl;
//提供服务
ServiceIO(new_sock);
}
return 0;
}
客户端
客户端创建套接字
客户端在调用socket函数创建套接字时,参数设置与服务端创建套接字时是一样的
//1. 创建套接字socket
int sock = socket(AF_INET, SOCK_STREAM, 0);//TCP采用流式套接
if(sock < 0)
{
std::cerr << "socket error!" << std::endl;
return 2;
}
注意:客户端不需要进行显示的绑定和监听
原因如下:
1)服务端要进行绑定是因为服务端的IP地址和端口号必须要众所周知,不能随意改变
客户端要绑定,但是无需显示的绑定, 对于客户端来说不需要固定的一个端口,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个端口号进行绑定
2)服务端需要进行监听是因为服务端需要通过监听来获取新连接
但是不会有人主动连接客户端,而是客户端主动的建立连接(因为它需要服务),服务端被动的接受连接(因为它提供服务), 因此客户端是不需要进行监听操作的,
引入命令行参数
客服端需要访问服务器, 客户端必须要知道它要连接的服务端的IP地址和端口号
因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信
所以我们引入命令行参数: 后序我们是这样启动客户端的:./udp_client server_ip server_port
所以有3个命令行参数
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " server_ip server_port" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);//使用说明
return 1;
}
//通过命令行参数拿到服务器的ip和port
std::string svr_ip = argv[1];//服务器的IP地址-字符串IP
uint16_t svr_port = (uint16_t)atoi(argv[2]);//argv[2]是字符串->转为整数,此时是主机序
//.................
}
客户端连接服务器
由于客户端不需要显示的绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求
connect函数
函数作用: 发起连接请求的函数
#include <sys/socket.h>
int connect(int socket, const struct sockaddr *address, socklen_t address_len);
参数说明
参数说明:
- sockfd:特定的套接字,表示通过该套接字发起连接请求
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等
- addrlen:传入的addr结构体的长度
第二个,第三个参数表示要连接的服务器的相关属性信息
返回值说明
连接成功返回0,连接失败返回-1,同时错误码会被设置
注意:
服务器处于listen状态,并且一直accept,等待客户端连接,
客户端不是不需要进行绑定端口,而是不需要我们自己进行绑定操作,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个端口号进行绑定, 通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方,即:如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器
调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求
- 而我们已经在上述命令行参数中获得了服务器的IP和port,根据情况转化后使用(主机序->网络字节序,字符串IP->整数IP)
struct sockaddr_in server; //填写服务器的网络信息:表示连接哪一个服务器,端口号是多少
bzero(&server, sizeof(server));//把结构体清0
server.sin_family = AF_INET;//协议家族
//inet_addr该函数做两件事情
//1. 将点分十进制的字符串风格的IP,转化成为4字节IP(一个整数)
//2. 将4字节IP由主机序列转化成为网络序列
server.sin_addr.s_addr = inet_addr(svr_ip.c_str()); //svr_ip.c_str():字符串风格的IP地址->转化为4字节的网络大端的IP地址
server.sin_port = htons(svr_port); // svr.port是主机序->网络字节序
//连接服务器
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0)
{
std::cout << "connect server failed !" << std::endl;
return 3;
}
std::cout << "connect success!" << std::endl;
客户端发送请求
当客户端连接到服务端后,客户端就可以向服务端发送数据了
- 此处我们可以让客户端将用户输入的数据发送给服务端,发送时调用write函数向套接字当中写入数据即可
当客户端将数据发送给服务端后,由于服务端读取到数据后还会把信息写回来
- 因此客户端在发送数据后还需要调用read函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误
// 进行正常的业务请求了
while(true)
{
std::cout << "Please Enter# ";
char buffer[1024];
fgets(buffer, sizeof(buffer)-1, stdin);//读取的内容放到buffer里面
//向服务端写入数据
write(sock, buffer, strlen(buffer));
//读取服务端写回来的数据
ssize_t s = read(sock, buffer, sizeof(buffer)-1);
if(s>0)
{
buffer[s] = 0;//读取到的内容当成字符串,最后置\0
std::cout << "server echo# " << buffer << std::endl;
}
}
tcp_client.cc
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include<iostream>
#include <strings.h>
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " server_ip server_port" << std::endl;
}
//客服端需要知道服务器的ID和port
// ./udp_client server_ip server_port
int main(int argc,char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);//使用说明
return 1;
}
//通过命令行参数拿到服务器的ip和port
std::string svr_ip = argv[1];//服务器的IP地址-字符串IP
uint16_t svr_port = (uint16_t)atoi(argv[2]);//argv[2]是字符串->转为整数,此时是主机序
//1. 创建套接字socket
int sock = socket(AF_INET, SOCK_STREAM, 0);//TCP采用流式套接
if(sock < 0)
{
std::cerr << "socket error!" << std::endl;
return 2;
}
//2.客户端连接服务器
struct sockaddr_in server; //填写服务器的网络信息:表示连接哪一个服务器,端口号是多少
bzero(&server, sizeof(server));//把结构体清0
server.sin_family = AF_INET;//协议家族
//inet_addr该函数做两件事情
//1. 将点分十进制的字符串风格的IP,转化成为4字节IP(一个整数)
//2. 将4字节IP由主机序列转化成为网络序列
server.sin_addr.s_addr = inet_addr(svr_ip.c_str()); //svr_ip.c_str():字符串风格的IP地址->转化为4字节的网络大端的IP地址
server.sin_port = htons(svr_port); // svr.port是主机序->网络字节序
//连接服务器
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0)
{
std::cout << "connect server failed !" << std::endl;
return 3;
}
std::cout << "connect success!" << std::endl;
//3.客户端发送请求
// 进行正常的业务请求了
while(true)
{
std::cout << "Please Enter# ";
char buffer[1024];
fgets(buffer, sizeof(buffer)-1, stdin);//读取的内容放到buffer里面
//向服务端写入数据
write(sock, buffer, strlen(buffer));
//读取服务端写回来的数据
ssize_t s = read(sock, buffer, sizeof(buffer)-1);
if(s>0)
{
buffer[s] = 0;//读取到的内容当成字符串,最后置\0
std::cout << "server echo# " << buffer << std::endl;
}
}
return 0;
}
测试
测试时我们先启动服务端,然后通过netstat
命令进行查看,此时我们就能看到一个名为tcp_server的服务进程,该进程当前处于监听状态
然后再通过./tcp_client IP地址 端口号
的形式运行客户端,此时客户端就会向服务端发起连接请求,服务端获取到请求后就会为该客户端提供服务
当连接上的时候,服务端可以通过打印的IP地址和端口号识别出对应的客户端,
发送消息的时候:客户端通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息
如果此时客户端退出了,那么服务端在调用read函数时得到的返回值就是0,此时服务端也就知道客户端退出了,进而会终止对该客户端的服务
注意: 此时是服务端对该客户端的服务终止了,而不是服务器终止了,此时服务器依旧在运行,它在等待下一个客户端的连接请求
上述单执行流服务器存在的问题
像上面那样,当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务
但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端
只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端
为什么会出现上述的问题呢?
1)因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务
2)当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,并且服务是死循环执行,只能服务完当前客户端后才能继续服务下一个客户端
为什么客户端为什么会显示连接成功
当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了
实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的
如何解决上述的问题?
单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的
将服务器改为多执行流的,此时就要引入多进程或多线程
多进程版的TCP网络程序
如何将上述的代码做修改从单执行流服务器改为多进程版的服务器
1)当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务
2)由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕
问题来了:子进程是否会继承父进程的文件描述符表
会!文件描述符表是属于一个进程的,子进程创建后,会创建自己的文件描述符表,但是里面的内容是继承父进程的文件描述符表的内容, 即:父子进程各自有自己的文件描述符表,但是指向的内容是一样的!
例如:父进程打开了一个文件,该文件对应的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件
创建子进程之后
父进程创建子进程后,父子进程之间会保持独立性,此时父进程文件描述符表的变化不会影响子进程
例子:之前的进程间通信-匿名管道
- 父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符
- 此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的
- 此后父子进程就可以通过这个管道进行单向通信了
对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务
等待子进程退出问题
当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏,因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待
这里有:阻塞式等待与非阻塞式等待可以选择
- 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务
- 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出
总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不太好!
此时我们可以考虑让服务端不等待子进程退出,常见的方式有两种:
- 捕捉SIGCHLD信号,将其处理动作设置为忽略
- 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务
方法1-捕捉SIGCHLD信号
实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了
signal(SIGCHLD, SIG_IGN); //在Linux中父进程忽略子进程的SIGCHLD信号,当子进程退出时,会直接释放子进程的资源
//服务端对外提供服务的代码
for (;;)
{
struct sockaddr_in peer;//记录远端(客户端)的网络的相关属性
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len);//获取客户端谁来访问我
if(new_sock < 0)//获取新链接失败,继续下一次获取
{
continue;
}
//提供服务
//为了方便打印观察是谁连接的我
uint16_t cli_port = ntohs(peer.sin_port);//客户端的端口->拿到的是网络字节序->需要转为主机序
std::string cli_ip = inet_ntoa(peer.sin_addr);//客户端的IP地址 -> 拿到的是4字节IP地址->转为字符串IP
std::cout << "get a new link -> : [" << cli_ip << ":" << cli_port <<"]# " << new_sock << std::endl;
//创建子进程帮我们执行任务
pid_t id = fork();
if (id == 0)//child
{
//处理请求
close(listen_sock);
ServiceIO(new_sock);
close(new_sock);
exit(0); //子进程提供完服务自动退出
}
//父进程关闭new_sock
close(new_sock);
}
我们可以写一个监视脚本对服务进程进行监控
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
一开始没有客户端连接该服务器,此时服务进程只有一个,该服务进程就是不断获取新连接的进程,而获取到新连接后也是由该进程创建子进程为对应客户端提供服务的
此时我们运行一个客户端,让该客户端连接服务器,此时服务进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务
如果再有一个客户端连接服务器,此时服务进程会再创建出一个子进程,让该子进程为这个客户端提供服务
当客户端一个个退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接
为什么打印出来的new_socket都是4呢?
因为我们创建子进程帮我们执行任务的时候,子进程要关闭继承自父进程的listen_sock
文件描述符,而父进程关闭了为了子进程创建的new_socket
,因为各自是两张文件描述符表,指向同样的文件,所以互不影响!
然后下一次再有客户端连接,因为父进程的4号文件描述符是空缺的,所以分配给new_socket的值就是4
方法2-让孙子进程提供服务
我们也可以让服务端创建出来的子进程再次进行fork,让孙子进程为客户端提供服务, 此时我们就不用等待孙子进程退出了
此时三个进程的含义:
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程
- 爸爸进程:由爷爷进程调用fork函数创建出来的进程
- 孙子进程:由爸爸进程调用fork函数创建出来的进程,该进程调用函数为客户端提供服务
此时我们让爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功,此后服务进程就能继续调用accept函数获取其他客户端的连接请求
为什么不需要等待孙子进程退出
由于爸爸进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收孙子进程,所以服务进程(爷爷进程)是不需要等待孙子进程退出的
关闭对应的文件描述符
服务进程(爷爷进程)调用accept函数获取到新连接后,会让孙子进程为该连接提供服务,此时服务进程已经将文件描述符表继承给了爸爸进程,而爸爸进程又会调用fork函数创建出孙子进程,然后再将文件描述符表继承给孙子进程
而父子进程创建后,它们各自的文件描述符表是独立的,不会相互影响.因此服务进程在调用fork函数后,服务进程就不需要再关心刚才从accept函数获取到的文件描述符了,此时服务进程就可以调用close函数将该文件描述符进行关闭
同样的,对于爸爸进程和孙子进程来说,它们是不需要关心从服务进程(爷爷进程)继承下来的监听套接字的,因此爸爸进程可以将监听套接字关掉
为什么要关闭文件描述符呢?
- 对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉.因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少
- 而对于爸爸进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字.实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上.因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响
//服务端对外提供服务的代码
for( ; ; )
{
struct sockaddr_in peer;//记录远端(客户端)的网络的相关属性
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len);//获取客户端谁来访问我
if(new_sock < 0)//获取新链接失败,继续下一次获取
{
continue;
}
//连接成功
//对端的网络相关信息被放到了peer结构体里面
//为了方便打印观察是谁连接的我
uint16_t cli_port = ntohs(peer.sin_port);//客户端的端口->peer.sin_port拿到的是网络字节序->需要用ntohs函数转为主机序
std::string cli_ip = inet_ntoa(peer.sin_addr);//客户端的IP地址->peer.sin_addr拿到的是4字节IP地址->用inet_ntoa转为字符串IP
std::cout << "get a new link -> : [" << cli_ip << ":" << cli_port <<"]# " << new_sock << std::endl;
//提供服务
pid_t id = fork();
if (id < 0)
{
continue; // 创建失败
}
else if (id == 0) // child
{
// 子进程会继承父进程的文件描述符,相当于复制一份,
// 父子进程的文件描述符互不影响,故关闭子进程的监听套接字文件描述符
close(listen_sock);
if (fork() > 0) exit(0); // 子进程退出,任务交给孙子进程执行
//这里向后走的进程,其实是孙子进程 爷孙进程不需要等待,
//孙子进程的父进程退出, 变成孤儿进程,被OS领养
ServiceIO(new_sock);//孙子进程提供服务
close(new_sock); // 使用完就关掉,防止文件描述符泄漏
exit(0);
}
else //parent
{
close(new_sock); // 关闭父进程的newsock,以待重新获取连接
waitpid(id, nullptr, 0); // 等待子进程退出
}
}
现象:
继续使用监控脚本对服务进程进行实时监控
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
1)此时没有客户端连接服务器,因此也是只监控到了一个服务进程,该服务进程正在等待客户端的请求连接
2)此时我们运行一个客户端,让该客户端连接当前这个服务器,此时服务进程会创建出爸爸进程,爸爸进程再创建出孙子进程,之后爸爸进程就会立刻退出,而由孙子进程为客户端提供服务.因此这时我们只看到了两个服务进程,其中一个是一开始用于获取连接的服务进程,还有一个就是孙子进程,该进程为当前客户端提供服务,它的PPID为1,表明这是一个孤儿进程
3)当我们运行第二个客户端连接服务器时,此时就又会创建出一个孤儿进程为该客户端提供服务
4)当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的服务进程
多线程版的TCP网络程序
既然可以用进程处理一个一个的请求->当然也可以使用线程
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构.而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现
当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务
线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题.但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源
此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端
问题来了:曾经被主线程打开的fd,新线程是否能看到,是否共享
各个线程共享同一张文件描述符表!
- 文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表
- 而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表
- 因此当服务进程(主线程)调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的
注意:文件描述符关闭的问题
因为此时所有线程看到的都是同一张文件描述符表,因此当某个线程要对这张文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程
- 对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行
- 因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭
- 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了
void *HandlerRequest(void *args)
{
pthread_detach(pthread_self());//线程分离
int new_sock = *(int*)args;
delete (int*)args;
//用完了把自己的套接字关了就可以
ServiceIO(new_sock);//提供服务
close(new_sock);//关闭文件描述符
}
//服务端对外提供服务的代码
for( ; ; )
{
struct sockaddr_in peer;//记录远端(客户端)的网络的相关属性
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len);//获取客户端谁来访问我
if(new_sock < 0)//获取新链接失败,继续下一次获取
{
continue;
}
//连接成功
//对端的网络相关信息被放到了peer结构体里面
//为了方便打印观察是谁连接的我
uint16_t cli_port = ntohs(peer.sin_port);//客户端的端口->peer.sin_port拿到的是网络字节序->需要用ntohs函数转为主机序
std::string cli_ip = inet_ntoa(peer.sin_addr);//客户端的IP地址->peer.sin_addr拿到的是4字节IP地址->用inet_ntoa转为字符串IP
std::cout << "get a new link -> : [" << cli_ip << ":" << cli_port <<"]# " << new_sock << std::endl;
//提供服务
pthread_t tid;
int* pram = new int(new_sock);
pthread_create(&tid, nullptr, HandlerRequest, pram);
}
注意此时编译的时候:服务端需要指明线程库: g++ -o $@ $^ -std=c++11 -lpthread
监控脚本:由于我们现在要监测的是一个个的线程,因此在监控时使用的不再是ps -axj
命令,而是ps -aL
命令
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
1)运行服务端,通过监控可以看到,此时只有一个服务线程,该服务线程就是主线程,它现在在等待客户端的连接到来
2)当一个客户端连接到服务端后,此时主线程就会创建一个新线程,为该客户端提供服务
3)当第二个客户端发来连接请求时,主线程会进行相同的操作,最终再创建出一个新线程为该客户端提供服务,此时服务端当中就有了三个线程
4)此时无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下最初的主线程仍在等待新连接的到来
线程池版的TCP网络程序
单纯只使用多线程存在的问题
- 每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁
- 这样做不仅麻烦,而且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程
- 如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程
- 计算机当中的线程越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高
- 一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答
解决方法:使用线程池
1)可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程
2)当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒
3)服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大.此外,如果有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务
线程池代码
线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度
在线程池里面有一个任务队列,当有新的任务到来的时候,就可以将任务Push到线程池当中,在线程池当中我们默认创建了5个线程,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用该任务对应的Run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入休眠状态
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <unistd.h>
#include <pthread.h>
namespace Mango
{
const int g_num = 5;
template<class T>
class ThreadPool
{
private:
//构造函数+拷贝构造+赋值重载都禁用掉
//构造函数必须得实现,因为没有构造函数就没办法初始化
//但是必须私有化(单例模式) ->这个类不能用来定义对象
ThreadPool(int num = g_num)
:_num(num)
{
//初始化互斥锁和条件变量,属性设置为nullptr
pthread_mutex_init(&_mtx,nullptr);
pthread_cond_init(&_cond,nullptr);
}
ThreadPool(const ThreadPool<T>& tp) = delete;
ThreadPool<T>& operator=(ThreadPool<T> &tp) = delete;
public:
~ThreadPool()
{
//释放锁和条件变量
pthread_mutex_destroy(&_mtx);
pthread_cond_destroy(&_cond);
}
void Lock() //加锁
{
pthread_mutex_lock(&_mtx);
}
void UnLock() //解锁
{
pthread_mutex_unlock(&_mtx);
}
void Wait() //在条件变量下等待
{
pthread_cond_wait(&_cond,&_mtx);
}
void WaitUp()//唤醒在条件变量下等待的进程
{
pthread_cond_signal(&_cond);
}
bool IsEmpty()//判断任务队列中是否有任务(是否为空)
{
return task_queue.empty();
}
static ThreadPool<T> *GetInstance()//获得这个唯一的对象
{
//单例本身会在任何场景,任何环境下被调用
//GetInstance():被多线程重入,进而导致线程安全的问题
//我们只需要抱着检测当前创建对象和检测的过程是原子的就行
//如何解决呢?->加一把锁
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//因为是static,所以不用初始化也不用释放
if(ins == nullptr)//当前单例对象还没有被创建
{
pthread_mutex_lock(&lock);
//双判定,减少锁的争用,提高获取单例的效率!
if(ins == nullptr)
{
ins = new ThreadPool<T>();
ins->InitThreadPool();//初始化线程池
std::cout << "首次加载对象" << std::endl;
}
pthread_mutex_unlock(&lock);
}
//单例对象已经被创建过了,直接返回
return ins;
}
void PushTask(const T& in)
{
//访问临界资源前先加锁
Lock();
task_queue.push(in);
UnLock();
//唤醒在条件变量下等待的线程执行任务
WaitUp();
}
//拿任务的时候,因为是在加锁的上下文中拿任务,所以可以直接拿
void PopTask(T* out)
{
*out = task_queue.front();
task_queue.pop();
}
//在类中要让线程执行类内成员方法,是不可行的!因为类内的成员函数第一个参数是默认的this指针!!!
//因为现在是静态方法,不能访问类内的属性
//解决办法:pthread_create的第四个参数传递this指针
static void *Rountine(void *args)
{
pthread_detach(pthread_self());//线程分离,后序就不需要等待该进程了
ThreadPool<T>* tp = (ThreadPool<T>*)args;
//竞争任务
while(1)
{
tp->Lock();//任务队列是临界资源,要访问临界资源,先加锁保护(先把任务队列锁住)
//检测任务队列当中有没有任务,因为可能存在伪唤醒,所以用while不能用if
while(tp->IsEmpty())
{
//任务队列为空,线程该做什么呢??-> 将线程挂起,等待有任务的时候被唤醒
tp->Wait();
}
//来到这里,说明有任务了
T t;
tp->PopTask(&t);//输出型参数
//先解锁在运行任务
//把任务从任务队列当中取出了,这个任务也就从任务队列当中移除
//这个任务属于当前线程,不再属于临界资源
//所以处理任务应该在解锁之后处理,当我们把锁释放掉之后,当前线程在处理这个任务,
//其它线程可能征征用锁,然后判断+处理,所以可能存在有多个线程同时跑任务
tp->UnLock();
t();//t.operator() -> 相当于t.Run()
}
}
void InitThreadPool()//初始化线程池创建线程
{
pthread_t tid;
for(int i = 0;i<_num;i++)
{
//第四个参数传this指针,方便我们静态成员函数Rountine访问类内的成员
pthread_create(&tid,nullptr,Rountine,(void*)this);
}
}
private:
int _num;//表示线程池中有多少个线程
// 外部存在一个或者多个线程向任务队列当中塞入任务
// 线程池内部有多个线程竞争式的从任务队列当中拿任务
// ->典型的生产者消费者模型 ,这个任务队列就是临界资源
std::queue<T> task_queue;//任务队列->是临界资源
pthread_mutex_t _mtx;//互斥锁,保护临界资源
pthread_cond_t _cond;//条件变量,当没有任务的时候,线程要挂起等待
static ThreadPool<T> *ins;//指向这个类唯一的对象
};
//在类外初始化
template<class T>
//类型 作用域::名字
ThreadPool<T>* ThreadPool<T>::ins = nullptr;
}
任务类设计
设计一个任务类,该任务类当中需要包含客户端对应的套接字,表示该任务是为哪一个客户端对应操作的套接字
任务类当中需要包含一个Run方法,当线程池中的线程拿到任务后就会直接调用这个Run方法对该任务进行处理,而实际处理这个任务的方法就是服务类当中的Service函数,服务端就是通过调用Service函数为客户端提供服务的
#pragma once
#include <iostream>
#include <cstring>
#include <unistd.h>
namespace Mango
{
class Task
{
private:
int sock;
public:
Task() : sock(-1) {}
Task(int _sock) : sock(_sock)
{
}
int Run() //和ServiceIO函数一致
{
while(true)
{
//提供服务,我们是一个死循环
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = 0; //将获取的内容当成字符串
std::cout << "client# " << buffer ;
std::string echo_string = ">>>server<<<, ";
echo_string += buffer;
write(sock, echo_string.c_str(), echo_string.size());
}
else if (s == 0)
{
std::cout << "client quit ..." << std::endl;
break;
}
else
{
std::cerr << "read error" << std::endl;
break;
}
}
close(sock);//关闭文件描述符
}
int operator()() //对象() -》相当于是对象.operator() 然后去调用Run函数
{
Run();
}
~Task() {}
};
}
//服务端对外提供服务的代码
for( ; ; )
{
struct sockaddr_in peer;//记录远端(客户端)的网络的相关属性
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len);//获取客户端谁来访问我
if(new_sock < 0)//获取新链接失败,继续下一次获取
{
continue;
}
//连接成功
//对端的网络相关信息被放到了peer结构体里面
//为了方便打印观察是谁连接的我
uint16_t cli_port = ntohs(peer.sin_port);//客户端的端口->peer.sin_port拿到的是网络字节序->需要用ntohs函数转为主机序
std::string cli_ip = inet_ntoa(peer.sin_addr);//客户端的IP地址->peer.sin_addr拿到的是4字节IP地址->用inet_ntoa转为字符串IP
std::cout << "get a new link -> : [" << cli_ip << ":" << cli_port <<"]# " << new_sock << std::endl;
//使用单例模式的线程池
//1. 构建一个任务
Task t(new_sock);
//2. 将任务push到后端的线程池即可,然后主线程继续获取连接
//服务就由后端的线程池提供服务
ThreadPool<Task>::GetInstance()->PushTask(t);
}
代码测试
此时我们再重新编译服务端代码,并用以下监控脚本查看服务端的各个线程
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
运行服务端后,只有一个是接收新连接的服务线程
此时当客户端连接服务器后,线程池被创建出来,一下子就有了5个线程,将一个任务对象后塞入任务队列,此时线程池中的5个线程就会有一个线程从任务队列当中获取到该任务,并执行该任务的处理函数为客户端提供服务
当第二个客户端发起连接请求时,服务端也会将其作为一个任务类塞到任务队列,然后线程池当中的线程再从任务队列当中获取到该任务进行处理,此时也是不同的执行流为这两个客户端提供的服务,因此这两个客户端也是能够同时享受服务的
与之前不同的是,无论现在有多少客户端发来请求,在服务端都只会有线程池当中的5个线程为之提供服务,线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出
关于绑定失败的问题
资源未释放干净
例子:
1)当我们在测试网络代码时,先将服务端绑定8081端口运行,然后运行客户端,并让客户端连接当前服务器
2)在有客户端连接服务端的情况下,如果直接将服务端关闭,此时服务端要想再次绑定8081号端口运行,就可能会绑定失败
绑定失败这个问题涉及TCP通信中双方状态变化的一个细节,这里暂时无法解释清楚,这里想说明的就是,绑定是有可能失败的,这里绑定失败实际是因为服务端退出时没有将资源释放干净
端口号已被其他程序绑定
绑定失败还有可能因为当前端口号已经被其他程序绑定了
例子:
比如一个程序已经绑定了8081号端口,此时另一个程序也想绑定8081号端口,此时该程序就会绑定失败
这实际也就验证了一个端口号只能被一个进程所绑定这样的规则,此时也就确保了端口号到服务之间的映射本身就具备唯一性
无法绑定的端口号
我们自己编写的服务器代码在绑定端口号时,尽量不要绑定1024以下的端口号
一般云服务器只能绑定1024及其往上的端口号,因为1024以下的端口已经约定俗成被其他一些比较成熟的服务所使用了,如果我们绑定1024以下的端口号,那么会绑定失败
例子:
因此我们一般只能绑定1024及其往上的端口号,最好绑定8000及其网上的端口号
- 如果想要测试编写的网络代码,就要在你的云服务器上开放安全组
TCP协议通信流程
- 创建socket的过程,socket()本质是打开文件 —— 仅仅有系统相关的内容
- bind():将当前的文件描述符和ip/port绑定在一起; 如果这个端口已经被其他进程占用了, 就会bind失败
- listen(): 声明当前这个文件描述符作为一个服务器的文件描述符, 为后面的accept做好准备,本质是设置该socket的监听状态,允许别人来连接我
- accept():获取新链接到应用层,是以fd为代表的
- 所谓新链接就是,当有很多链接连上我们的服务器时,OS中会存在大量的链接,definitely OS要管理这些已经建立好的链接:先描述,再组织.所谓的“连接“,在OS层面,本质就是一个个描述连接的结构体
- read/write:本质就是进行网络通信,但是对于用户讲,相当于进行普通的文件读写.
- close(fd):关闭文件
- ①系统层面,释放曾经申请的文件资源、连接资源等 ②网络层面,通知对方,我的连接已经关闭了
- connect():本质是发起连接
- ①系统层面,就是构建一个请求报文发送过去 ②网络层面,发起tcp链接的三次握手
- close(),client && server本质在网络层面,就是进行四次挥手
TCP 和 UDP 对比
- 可靠传输 vs 不可靠传输
- 有连接 vs 无连接
- 字节流 vs 数据报