目录
地址转换函数
字符串IP转整数IP
整数IP转字符串IP
关于inet_ntoa
简单的单执行流TCP网络程序
TCP socket API 详解及封装TCP socket
服务端创建套接字
服务端绑定
服务端监听
服务端获取连接
服务端处理请求
客户端创建套接字
客户端连接服务器
客户端发起请求
服务器测试
单执行流服务器的弊端
多进程的TCP网络程序
多级创建子进程
捕捉SIGCHLD信号
多线程的TCP网络程序
线程池的TCP网络程序
地址转换函数
字符串IP转整数IP
本节只介绍基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位的IP 地址。但是我们通常用点分十进制的字符串表示IP 地址,以下函数可以在字符串表示和in_addr表示之间转换;
字符串转in_addr(整数ip)的函数:
- inet_aton函数的函数原型如下:
int inet_aton(const char *cp, struct in_addr *inp);
参数说明:
- cp:待转换的字符串IP。
- inp:转换后的整数IP,这是一个输出型参数。
返回值说明:
- 如果转换成功则返回一个非零值,如果输入的地址不正确则返回零值。
- inet_addr函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
参数说明:
- cp:待转换的字符串IP。
返回值说明:
- 如果输入的地址有效,则返回转换后的整数IP;如果输入的地址无效,则返回INADDR_NONE(通常为-1)。
- inet_pton函数的函数原型如下:
int inet_pton(int af, const char *src, void *dst);
参数说明:
- af:协议家族。
- src:待转换的字符串IP。
- dst:转换后的整数IP,这是一个输出型参数。
返回值说明:
- 如果转换成功,则返回1。
- 如果输入的字符串IP无效,则返回0。
- 如果输入的协议家族af无效,则返回-1,并将errno设置为EAFNOSUPPORT。
整数IP转字符串IP
in_addr转字符串的函数:
- inet_ntoa函数的函数原型如下:
char *inet_ntoa(struct in_addr in);
参数说明:
- in:待转换的整数IP。
返回值说明:
- 返回转换后的字符串IP。
- inet_ntop函数的函数原型如下:
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数说明:
- af:协议家族。
- src:待转换的整数IP。
- dst:转换后的字符串IP,这是一个输出型参数。
- size:用于指明dst中可用的字节数。
返回值说明:
- 如果转换成功,则返回一个指向dst的非空指针;如果转换失败,则返回NULL。
代码示例:
- 我们最常用的两个转换函数是inet_addr和inet_ntoa,因为这两个函数足够简单。这两个函数的参数就是需要转换的字符串IP或整数IP,而这两个函数的返回值就是对应的整数IP和字符串IP。
- 其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此函数接口是void *addrptr。
- 实际这些转换函数都是为了满足某些打印场景的,除此之外,更多的是用来做某些数据分析,比如网络安全方面的数据分析。
关于inet_ntoa
inet_ntoa这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果。那么是否需要调用者手动释放呢?
man手册上说,inet_ntoa函数,是把这个返回结果放到了静态存储区。这个时候不需要我们手动进行释放。
那么问题来了, 如果我们调用多次这个函数, 会有什么样的效果呢? 参见如下代码:
运行结果如下:
因为inet_ntoa把结果放到自己内部的一个静态存储区, 这样第二次调用时的结果会覆盖掉上一次的结果.
如果有多个线程调用 inet_ntoa, 是否会出现异常情况呢?
inet_ntoa函数内部只在静态存储区申请了一块区域,用于存储转换后的字符串IP,那么在线程场景下这块区域就叫做临界区,多线程在不加锁的情况下同时访问临界区必然会出现异常情况。并且在APUE中,也明确提出inet_ntoa不是线程安全的函数。
多线程调用inet_ntoa函数测试:
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
void* Func1(void* p) {
struct sockaddr_in* addr = (struct sockaddr_in*)p;
while (1) {
char* ptr = inet_ntoa(addr->sin_addr);
printf("addr1: %s\n", ptr);
}
return NULL;
}
void* Func2(void* p) {
struct sockaddr_in* addr = (struct sockaddr_in*)p;
while (1) {
char* ptr = inet_ntoa(addr->sin_addr);
printf("addr2: %s\n", ptr);
}
return NULL;
}
int main() {
pthread_t tid1 = 0;
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
pthread_create(&tid1, NULL, Func1, &addr1);
pthread_t tid2 = 0;
pthread_create(&tid2, NULL, Func2, &addr2);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
但是实际在centos7上测试时,在多线程场景下调用inet_ntoa函数并没有出现问题,可能是该函数内部的实现加了互斥锁,这就跟接口本身的设计也是有关系的。
在多线程环境下, 推荐使用inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题;
简单的单执行流TCP网络程序
TCP socket API 详解及封装TCP socket
socket()函数:
我们用man指令查看一下 socket()函数:
- socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符;
- 应用程序可以像读写文件一样用read/write在网络上收发数据;
- 如果socket()调用出错则返回-1;
- 对于IPv4,family参数指定为AF_INET;
- 对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议
- protocol参数的介绍从略,指定为0即可。
服务端创建套接字
下面我们将TCP服务器封装成一个类,当我们定义出一个服务器对象后需要对服务器进行初始化,而初始化TCP服务器首先要执行的操作是创建一个套接字。
TCP服务器在调用socket函数创建套接字时,参数设置如下:
- 协议家族选择AF_INET,因为我们要进行的是网络通信。
- 创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
- 协议类型默认设置为0即可。
- 如果创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时也就没必要进行后续操作了,直接终止程序即可。
const int defaultfd = -1;
class TcpServer
{
public:
void InitServer()
{
//创建套接字
listensock_ = socket(AF_INET,SOCK_STREAM,0);
if(listensock_ < 0)
{
lg(Fatal, "create socket, errno: %d, errstring: %s", errno, strerror(errno));
exit(SocketError);
}
lg(Info, "create socket success, listensock_: %d", listensock_);
}
~TcpServer()
{
if(listensock_ >= 0) close(listensock_);
}
private:
int listensock_;
};
在创建实际TCP服务器和UDP服务器的套接字时,方法基本相同。但在选择服务类型时,TCP需要流式服务,而UDP则需要用户数据报服务。当析构服务器时,可以将服务器对应的文件描述符进行关闭。
这里我们将套接字名字设置为listensock_,我们在编写服务端监听的时候再进行说明
bind():
- 服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接;服务器需要调用bind绑定一个固定的网络地址和端口号;
- bind()成功返回0,失败返回-1。
- bind()的作用是将参数sockfd和myaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听myaddr所描述的地址和端口号;
- 前面讲过,struct sockaddr *是一个通用指针类型,myaddr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度;
我们的程序中对myaddr参数是这样初始化的:
1. 将整个结构体清零;
2. 设置地址类型为AF_INET;
3. 网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址;
4. 端口号为SERV_PORT,我们定义为9999;
服务端绑定
套接字创建完毕后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字后我们还需要调用bind函数进行绑定操作。
需要执行以下绑定步骤:
- 首先,定义一个struct sockaddr_in结构体,用于存储服务器网络相关的属性信息,如协议家族、IP地址和端口号。
- 在填充这些信息时,协议家族应设置为AF_INET,以表示使用IPv4协议。端口号是TCP服务器程序所使用的端口,需要在设置时使用htons函数将其从主机字节序转换为网络字节序。
- 服务器的IP地址可以选择设置为本地环回地址127.0.0.1,表示仅支持本地通信。若要支持网络通信,可以将服务器的IP地址设置为公网IP地址。
- 对于云服务器环境,在设置IP地址时,不需要显式绑定特定的IP地址。可以将IP地址设置为INADDR_ANY,表示服务器可以从本地任何一张网卡上接收数据。由于INADDR_ANY本质上表示0,因此在设置时不需要进行网络字节序的转换。
- 完成服务器网络属性的设置后,需要调用bind函数进行绑定操作。绑定是将文件描述符与网络地址关联的过程。如果绑定失败,则没有必要继续执行后续操作,可以直接终止程序。
- 由于TCP服务器在初始化时需要指定端口号,因此在服务器类中需要包含端口号成员变量。在实例化服务器对象时,需要向其传入一个端口号。由于我当前使用的是云服务器环境,因此在绑定TCP服务器的IP地址时,不需要显式绑定公网IP地址,可以直接使用INADDR_ANY进行绑定。因此,我在服务器类中没有引入IP地址成员变量。
由于TCP服务器初始化时需要服务器的端口号,因此在服务器类当中需要引入端口号,当实例化服务器对象时就需要给传入一个端口号。而由于我当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不需要绑定公网IP地址,直接绑定INADDR_ANY即可,因此我这里没有在服务器类当中引入IP地址。
const int defaultfd = -1;
const string defaultip = "0.0.0.0";
class TcpServer
{
public:
TcpServer(const int& serverport,const string& ip = defaultip)
:listensock_(defaultfd),
port(serverport)
ip_(ip)
{}
void InitServer()
{
//1.创建套接字
listensock_ = socket(AF_INET,SOCK_STREAM,0);
if(listensock_ < 0)
{
lg(Fatal, "create socket, errno: %d, errstring: %s", errno, strerror(errno));
exit(SocketError);
}
lg(Info, "create socket success, listensock_: %d", listensock_);
//2.绑定
struct sockaddr_in local;
bzero(&local,sizeof(0));
local.sin_family = AF_INET;
local.sin_port = htons(port);
inet_aton(INADDR_ANY,&(local.sin_addr));//换个接口写一下
if (bind(listensock_, (struct sockaddr *)&local, sizeof(local)) < 0)
{
lg(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
exit(BindError);
}
lg(Info, "bind socket success, listensock_: %d", listensock_);
}
~TcpServer()
{
if(listensock_ >= 0) close(listensock_);
}
private:
int listensock_;
uint16_t port;
};
注意:
当定义好struct sockaddr_in结构体后,最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空。bzero函数也可以对特定的一块内存区域进行清空。
TCP服务器绑定时的步骤与UDP服务器是完全一样的,没有任何区别。
服务端监听
UDP服务器的初始化步骤较为简单,主要包括创建套接字和绑定。相比之下,TCP服务器需要进行更复杂的操作。由于TCP服务器是面向连接的,客户端在发送数据之前需要先与服务器建立连接。因此,TCP服务器需要能够监听客户端的连接请求。为了实现这一功能,需要将TCP服务器创建的套接字设置为监听状态,以便等待和处理客户端的连接请求。
listen()函数:
设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
服务器监听
在完成套接字创建和绑定之后,TCP服务器需要进一步配置以监听新的连接请求。这是通过将套接字设置为监听模式来实现的,这样服务器就可以等待并处理来自客户端的连接请求。
如果监听操作失败,说明服务器无法正常接收客户端的连接请求。在这种情况下,没有必要进行后续的操作,因为监听失败意味着服务器无法正常工作。因此,当监听失败时,应当直接终止程序的执行。
const int defaultfd = -1;
const int backlog = 10; // 但是一般不要设置的太大
const string defaultip = "0.0.0.0";
class TcpServer
{
public:
TcpServer(const int& serverport,const string& ip = defaultip)
:listensock_(defaultfd),
port(serverport),
ip_(ip)
{}
void InitServer()
{
//1.创建套接字
listensock_ = socket(AF_INET,SOCK_STREAM,0);
if(listensock_ < 0)
{
lg(Fatal, "create socket, errno: %d, errstring: %s", errno, strerror(errno));
exit(SocketError);
}
lg(Info, "create socket success, listensock_: %d", listensock_);
//2.绑定
struct sockaddr_in local;
bzero(&local,sizeof(0));
local.sin_family = AF_INET;
local.sin_port = htons(port);
inet_aton(INADDR_ANY,&(local.sin_addr));//换个接口写一下
if (bind(listensock_, (struct sockaddr *)&local, sizeof(local)) < 0)
{
lg(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
exit(BindError);
}
lg(Info, "bind socket success, listensock_: %d", listensock_);
//服务端监听
if(listen(listensock_,backlog) < 0)
{
lg(Fatal,"listen error, error: %d, errstring: %s", errno, strerror(errno));
exit(ListenError);
}
lg(Info, "listen socket success, listensock_: %d", listensock_);
}
~TcpServer()
{
if(listensock_ >= 0) close(listensock_);
}
private:
int listensock_;
uint16_t port;
};
在初始化TCP服务器时,创建的套接字不仅仅是一个普通的套接字,它被特别地称为“监听套接字”。为了使代码更具描述性,我们将套接字的变量名设置为“listensock_”。
TCP服务器的初始化过程要求套接字的创建、绑定和监听都成功完成。只有当这三个步骤都顺利执行,TCP服务器的初始化才算完成。
服务端获取连接
TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。
accept函数
获取连接的函数叫做accept,该函数的函数原型如下:
函数说明:
- 三次握手完成后, 服务器调用accept()接受连接;
- 如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来;
参数说明:
- sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
- addr:addr是一个传出参数,accept()返回时传出客户端的网络相关的属性信息、协议家族、地址和端口号;
- addrlen:是一个输入输出型参数。传入的是调用者提供的,缓冲区addr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区);
返回值说明:
- 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。
我们的服务器程序结构是这样的:
如何理解accept函数返回的套接字?
当调用accept函数以获取连接时,它从监听套接字中获取连接。如果accept函数成功获取连接,它会返回接收到的套接字对应的文件描述符。
关于监听套接字与accept函数返回的套接字的作用:
- 监听套接字:主要用于接收客户端发来的连接请求。accept函数会持续从监听套接字中获取新的连接。
- accept函数返回的套接字:用于为本次获取到的连接提供服务。监听套接字的主要任务是持续接收新的连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。
总结:监听套接字主要负责等待和接收客户端的连接请求,而accept函数返回的套接字则负责为每个成功建立的连接提供服务。
举个例子来理解这个过程:
我们去商场吃饭的时候,经过饭店门口,都会有一个人在外面拿着菜单进行拉客,店员把客人拉进饭店,他的任务就算是完成了,接下来真正为为客人进行服务的是厨师。
这个过程中,拉客的店员就好比监听套接字,而accept函数返回的套接字就好比厨师。
服务端获取连接
在服务端处理连接时,需要注意以下几点:
- 首先,
accept
函数在获取连接时可能会失败。然而,TCP服务器不会因为某个连接获取失败而终止。因此,当服务端获取连接失败时,应该继续尝试获取其他连接。 - 其次,如果需要将获取到的连接对应的客户端IP地址和端口号信息输出,可以使用
inet_ntoa
函数将整数值的IP地址转换为字符串表示。同时,使用ntohs
函数将端口号从网络字节序转换为主机字节序。 - 值得注意的是,
inet_ntoa
函数在底层完成了两个任务:一是将网络字节序转换为主机字节序,二是将主机字节序的整数值IP地址转换为点分十进制的字符串表示。
enum
{
UsageError = 1,
SocketError,
BindError,
ListenError,
};
class TcpServer
{
public:
void Start()
{
lg(Info,"tcpServer is running...");
for(;;)
{
//1.获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
if(sockfd < 0)
{
lg(Warning,"accept error, errno: %d ,errstring: %s", errno, strerror(errno));
continue;//TCP服务器不会因为某个连接获取失败而终止。因此,当服务端获取连接失败时,应该继续尝试获取其他连接。
}
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(clientip));
}
}
private:
int listensock_;
uint16_t port;
};
服务端接收连接测试
现在,为了验证我们的服务器是否能够正常接收连接请求,我们将进行一个简单的测试。在启动服务器程序时,我们需要指定一个端口号作为服务器的监听端口。接着,我们将使用这个端口号来创建一个服务器对象,并对该对象进行初始化操作。完成初始化后,我们就可以启动服务器,使其开始监听并等待客户端的连接请求了。
void Usage(const string& proc)
{
cout << "Usage: " << proc << " port" << endl;
}
int main(int argc,char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit((UsageError));
}
uint16_t port = stoi(argv[1]);
// TcpServer* svr = new TcpServer(port);
unique_ptr<TcpServer> tcp_svr(new TcpServer(port));
return 0;
}
编译代码后,以./tcp_server 端口号的方式运行服务端。
运行服务端程序后,我们可以通过netstat命令查看系统中的网络连接状态。其中,可以看到一个名为tcp_server的服务程序正在运行,并绑定在端口8081上。由于服务器绑定了INADDR_ANY,表示它可以监听本地任何一张网卡上的数据。因此,服务器的本地IP地址显示为0.0.0.0,这意味着该TCP服务器可以从本地任何网卡中接收数据。
更重要的是,当前服务器处于LISTEN状态,这意味着它已准备好接收来自外部的连接请求。这表明服务器能够与外部客户端建立通信,并处理传入的请求。
尽管我们尚未编写客户端的代码,但我们仍然可以使用telnet
命令远程连接到该服务器。这是因为telnet
底层实际上使用的是TCP协议。
通过telnet
命令连接到当前的TCP服务器后,我们可以观察到服务器成功接收到了一个连接。为该连接提供服务的套接字对应的文件描述符是4。这是因为在初始化服务器时,文件描述符0、1、2分别对应于标准输入流、标准输出流和标准错误流。而文件描述符3在初始化时分配给了监听套接字。因此,当第一个客户端发起连接请求时,为其服务的套接字的文件描述符就是4。
如果此时我们再用其他窗口继续使用telnet命令,向该TCP服务器发起请求连接,此时为该客户端提供服务的套接字对应的文件描述符就是5。
我们直接用浏览器来访问这个TCP服务器,因为浏览器常见的应用层协议是http或https,其底层对应的也是TCP协议,因此浏览器也可以向当前这个TCP服务器发起请求连接。
服务端处理请求
在TCP服务器中,一旦监听套接字(listening socket)成功获取到连接请求,它不会直接为客户端提供服务。相反,它会使用accept
函数返回一个新的套接字,这个套接字专门用于与客户端进行通信。我们将这个套接字称为“服务套接字”。
为了确保通信的有效性,我们实现了一个简单的回声TCP服务器。在这个服务器中,当客户端发送数据时,服务端不会做任何复杂的处理,而是简单地将收到的数据原封不动地返回给客户端。这样,客户端在收到服务端的响应后,可以将这些数据进行打印输出,从而验证服务端和客户端之间的通信是否正常。
TCP服务器读取数据通过read函数读取,该函数的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示从该文件描述符中读取数据。
- buf:数据的存储位置,表示将读取到的数据存储到该位置。
- count:数据的个数,表示从该文件描述符中读取数据的字节数。
返回值说明:
- 如果返回值大于0,则表示本次实际读取到的字节个数。
- 如果返回值等于0,则表示对端已经把连接关闭了。
- 如果返回值小于0,则表示读取时遇到了错误。
read返回值为0表示对端连接关闭
这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:
- 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
- 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
- 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0
- 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。
这与TCP连接中的情况类似。客户端关闭连接后,服务端读取该连接的数据时会读取到0。这意味着服务端已经读取到了客户端发送的所有数据,并且客户端已经关闭了连接。因此,当服务端调用read函数并得到返回值为0时,它应该意识到客户端已经关闭了连接,并停止为该客户端提供服务。
write函数
TCP服务器写入数据的函数叫做write,该函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
- buf:需要写入的数据。
- count:需要写入数据的字节个数。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。
服务端处理请求
在TCP通信中,服务端通过服务套接字与客户端进行数据的读取和写入。这意味着服务套接字既承担着接收客户端数据的角色,也负责向客户端发送数据。这种设计正是TCP全双工通信的体现。
当服务端从服务套接字中读取客户端发送的数据时,如果read函数返回值为0,或者发生读取错误,此时应立即关闭服务套接字对应的文件描述符。这是因为文件描述符是一种有限的资源,如果我们不及时释放,会导致可用的文件描述符逐渐减少。因此,服务端在完成对客户端的服务后,应当及时关闭对应的文件描述符,以防止资源泄漏。
class TcpServer
{
public:
void Start()
{
lg(Info,"tcpServer is running...");
for(;;)
{
//1.获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
if(sockfd < 0)
{
lg(Warning,"accept error, errno: %d ,errstring: %s", errno, strerror(errno));
continue;//TCP服务器不会因为某个连接获取失败而终止。因此,当服务端获取连接失败时,应该继续尝试获取其他连接。
}
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(clientip));
lg(Info, "get a new link, listensock_: %d ,%s, %d", sockfd, clientip, clientport);
//2.根据新链接来进行通信
Service(sockfd,clientip,clientport);
close(sockfd);
}
}
void Service(int sockfd,const string& clientip,const uint16_t& clientport)
{
char buffer[4096];
while (true)
{
ssize_t n = read(sockfd,buffer,sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
cout << "client say#" << buffer << endl;
string echo_string = "tcpserver echo# ";
echo_string += buffer;
write(sockfd,echo_string.c_str(),sizeof(echo_string));
}
else if(n == 0)
{
lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
lg(Warning,"read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
break;
}
}
}
private:
int listensock_;
uint16_t port_;
string ip_;
};
客户端创建套接字
创建客户端对象后,我们需要对其进行初始化,其中最关键的步骤是创建套接字。与服务器端类似,客户端在调用socket函数时也需要设置相应的参数。
与服务器端不同,客户端不需要进行绑定和监听操作:
- 绑定:服务器需要绑定到一个特定的IP地址和端口号,以便客户端能够找到并连接到它。而客户端不需要进行绑定,因为当它尝试连接到服务器时,系统会自动为其分配一个临时的端口号。
- 监听:服务器需要通过监听来等待客户端的连接请求。而客户端没有需要监听的请求,因为它是主动发起连接的一方。
为了能够与指定的服务器进行通信,客户端除了自己的套接字外,还需要知道服务器的IP地址和端口号。这样,客户端才能通过套接字与指定的服务器建立连接并进行通信。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
using namespace std;
void Usage(const string& proc)
{
cout << "\n\rUsage: " << proc << " serverip serverport\n" <<endl;
}
// ./tcpclient serverip serverport
int main(int argc,char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
//创建套接字
int sockfd = 0;
sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd < 0)
{
cerr << "socket error" << endl;
return 1;
}
return 0;
}
客户端连接服务器
由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。
connect函数
客户端需要调用connect()连接服务器,该函数的函数原型如下:
参数说明:
- sockfd:特定的套接字,表示通过该套接字发起连接请求。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址.
客户端连接服务器
在客户端与服务器之间的通信中,客户端不是不需要进行绑定,而是不需要我们自己进行绑定操作。当客户端向服务器发起连接请求时,系统会为其随机分配一个端口号,并完成绑定操作。这样做的目的是为了确保通信双方能够唯一地标识,因为IP地址和端口号的组合是用来识别网络上的不同进程或服务的。也就是说,如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。
此外,当客户端调用connect函数向服务器发起连接请求时,需要提供服务器的网络信息,包括服务器的IP地址和端口号。这些信息是必要的,因为connect函数需要知道客户端想要连接到哪个服务器。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
using namespace std;
void Usage(const string& proc)
{
cout << "\n\rUsage: " << proc << " serverip serverport\n" <<endl;
}
// ./tcpclient serverip serverport
int main(int argc,char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
string serverip = argv[1];
uint16_t serverport = stoi(argv[2]);
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family = AF_INET;
server.sin_port = ntohs(serverport);
// server.sin_addr.s_addr = inet_addr(serverip.c_str());
inet_pton(AF_INET,serverip.c_str(),&(server.sin_addr));//换个接口使用
int cnt = 5;
int isreconnect = false;
do
{
// tcp客户端要不要bind?1 要不要显示的bind?0 系统进行bind,随机端口
// 客户端发起connect的时候,进行自动随机bind
int n = connect(sockfd,(struct sockaddr *)&server,sizeof(server));
if (n < 0)
{
isreconnect = true;
cnt--;
cerr << "connect error..., reconnect: " << cnt << endl;
sleep(2);
}
else
{
break;
}
} while (cnt && isreconnect);
return 0;
}
客户端发起请求
由于我们实现的是一个简单的回声服务器,当客户端成功连接到服务端后,客户端可以开始向服务端发送数据。为了实现这一功能,客户端可以使用write函数将用户输入的数据写入到套接字中。
在客户端发送数据后,服务端会读取数据并回显给客户端。为了获取服务端的响应数据,客户端需要调用read函数来读取从服务套接字中返回的数据。读取到的响应数据会被打印出来,以验证双方之间的通信是否正常进行。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
using namespace std;
void Usage(const string& proc)
{
cout << "\n\rUsage: " << proc << " serverip serverport\n" <<endl;
}
// ./tcpclient serverip serverport
int main(int argc,char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
string serverip = argv[1];
uint16_t serverport = stoi(argv[2]);
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family = AF_INET;
server.sin_port = ntohs(serverport);
// server.sin_addr.s_addr = inet_addr(serverip.c_str());
inet_pton(AF_INET,serverip.c_str(),&(server.sin_addr));//换个接口使用
//创建套接字
int sockfd = 0;
sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd < 0)
{
cerr << "socket error" << endl;
return 1;
}
//客户端链接服务器
int cnt = 5;
int isreconnect = false;
do
{
// tcp客户端要不要bind?1 要不要显示的bind?0 系统进行bind,随机端口
// 客户端发起connect的时候,进行自动随机bind
int n = connect(sockfd,(struct sockaddr *)&server,sizeof(server));
if (n < 0)
{
isreconnect = true;
cnt--;
cerr << "connect error..., reconnect: " << cnt << endl;
sleep(2);
}
else
{
break;
}
} while (cnt && isreconnect);
//客户端发起请求
while(true)
{
string message;
cout << "Please Enter@:" << endl;
getline(cin,message);
int n = write(sockfd,message.c_str(),message.size());
if(n < 0)
{
std::cerr << "write error..." << std::endl;
}
char inbuffer[4096];
n = read(sockfd,inbuffer,sizeof(inbuffer));
if(n > 0)
{
inbuffer[n] = 0;
std::cout << inbuffer << std::endl;
}
else if(n == 0)
{
cout << "server close!" << endl;
break;
}
else
{
cerr << "read error!" << endl;
break;
}
}
return 0;
}
服务器测试
在完成服务端和客户端的编写后,下一步是进行测试。首先启动服务端,然后使用netstat命令检查网络状态。通过netstat命令,我们可以看到名为tcp_server的服务进程正在监听状态等待客户端的连接请求。这意味着服务端已经准备好接收客户端的连接请求,并开始等待客户端的连接。
然后再通过./tcp_client IP地址 端口号的形式运行客户端,此时客户端就会向服务端发起连接请求,服务端获取到请求后就会为该客户端提供服务。
当客户端向服务端发送消息后,服务端可以通过打印的IP地址和端口号识别出对应的客户端,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息。
如果此时客户端退出了,那么服务端在调用read函数时得到的返回值就是0,此时服务端也就知道客户端退出了,进而会终止对该客户端的服务。
注意: 此时是服务端对该客户端的服务终止了,而不是服务器终止了,此时服务器依旧在运行,它在等待下一个客户端的连接请求。
单执行流服务器的弊端
以上我们实现的是单执行流的服务器。当我们仅用一个客户端连接服务端时,这一个客户端能够正常使用到服务端的服务。
当一个客户端正在与服务器进行通信时,另一个客户端尝试连接到服务器。虽然显示连接成功,但第二个客户端发送给服务端的数据并没有在服务端被打印出来,并且服务端也没有将该数据回显给该客户端。
只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。
通过实验观察,我们发现服务端在处理一个客户端请求后,才会继续处理下一个客户端的请求。这是因为当前的服务端实现是基于单执行流的模型,一次只能为一个客户端提供服务。
当服务端调用accept函数接受一个客户端连接后,就开始为该客户端提供服务。然而,在服务端为某个客户端提供服务的过程中,其他客户端可能会发起连接请求。但由于单执行流的限制,服务端必须先完成当前客户端的服务,然后才能处理下一个客户端的请求。
为什么第一个客户端退出后第二个客户端会回显成功?
- 当服务端正在为第一个客户端提供服务时,第二个客户端成功发起了连接请求。然而,服务端并没有通过调用accept函数来接收这个新的连接。实际上,在底层系统会为这种情况维护一个连接队列。没有通过accept函数获取的新连接会被放入这个连接队列中。这个连接队列的最大长度可以通过listen函数的第二个参数来指定。
- 因此,虽然服务端没有直接处理第二个客户端的连接请求,但在第二个客户端看来,连接是成功的。这是因为系统将连接请求放入了连接队列中,等待服务端空闲时再处理。
那么我们如何解决这种情况呢?
- 单执行流的服务器在处理客户端请求时,一次只能为一个客户端提供服务。这种方式的缺点是服务器的资源无法得到充分利用。为了提高服务器的效率和并发处理能力,通常会将服务器改为多执行流的设计。
- 要实现多执行流,需要引入多进程或多线程技术。通过创建多个进程或线程,服务器可以同时处理多个客户端的请求,从而更好地利用服务器的资源。这种设计可以大大提高服务器的处理能力和并发性能,满足更多客户端的需求。
- 因此,将服务器从单执行流改为多执行流是多进程或多线程技术应用的一个重要方面,它可以显著提升服务器的性能和并发处理能力。
多进程的TCP网络程序
当服务端通过accept函数获取到一个新的客户端连接时,它并不会由当前的执行流程(通常是主进程)直接为这个连接提供服务。相反,主进程会调用fork函数来创建一个新的子进程。然后,子进程将负责为这个新建立的连接提供服务。
由于父进程(主进程)和子进程是彼此独立的执行流,当父进程创建了子进程之后,它可以继续回到监听套接字上等待并接受新的连接请求,而无需等待或关注子进程何时完成对当前连接的服务。这种方式允许服务器同时处理多个客户端连接,提高了服务器的并发处理能力。
子进程继承父进程的文件描述符表
需要注意的是,文件描述符表是进程特有的资源,子进程在创建时会继承父进程的文件描述符表。这意味着,如果父进程打开了一个文件并获得了一个文件描述符(例如3号文件描述符),那么子进程也会拥有这个文件描述符,并指向同一个打开的文件。
进一步地,如果子进程再创建子进程,那么孙子进程同样会继承这个文件描述符,并指向相同的文件。这种继承关系使得多个进程可以共享同一个文件描述符,从而实现对同一文件的访问。
在进程创建子进程后,父子进程之间保持相对独立性。这意味着父进程的文件描述符表的变化不会对子进程产生影响。这种独立性在匿名管道的通信中表现得尤为明显。
父进程通过pipe函数创建了一个匿名管道,并获取了两个文件描述符,一个用于读取管道数据,另一个用于写入数据。子进程在创建时会继承这两个文件描述符。之后,父进程和子进程会分别关闭管道的读端和写端,这样做是为了确保单向通信的顺利进行。
在这种设置下,父子进程通过这个管道进行通信,而文件描述符表的变化不会相互干扰。同样地,套接字文件也是一样,子进程继承了父进程的套接字文件描述符,从而能够读写特定的套接字文件,实现对客户端的服务。
等待子进程问题
在父进程创建子进程后,为了防止子进程变成僵尸进程并导致内存泄漏,父进程需要等待子进程的退出。否则,子进程将无法释放其资源,导致系统资源的浪费。因此,服务端在创建子进程后需要调用wait或waitpid函数来等待子进程的完成。
关于阻塞式等待与非阻塞式等待:
- 阻塞式等待:如果服务端采用阻塞方式等待子进程,那么在为当前客户端提供服务期间,服务端将无法继续处理其他连接请求。这意味着服务端仍然是以串行方式处理客户端请求,无法充分利用多执行流的优点。
- 非阻塞式等待:虽然非阻塞方式允许服务端在子进程为客户端提供服务时继续接收新连接,但这种方式需要服务端保存所有子进程的PID,并不断检测子进程是否退出。这增加了服务端的开销和复杂性。
总体而言,无论是阻塞式还是非阻塞式等待子进程的方式都有其不足之处。为了更好地利用多执行流的优势,可以考虑让服务端不等待子进程的退出。这样可以进一步提高服务器的并发处理能力和效率。
要让父进程不等待子进程的退出,有几种常见的方法:
- 多级创建子进程:父进程可以创建子进程,然后让子进程再创建孙子进程。最后,孙子进程负责为客户端提供服务。这样,父进程和子进程可以继续处理其他任务,而父进程只需要关系儿子进程,因此不需要等待孙子进程的退出,最后子进程会由1号进程进行回收。
- 捕捉SIGCHLD信号:父进程可以捕捉SIGCHLD信号,并将该信号的处理动作设置为忽略。当子进程退出时,父进程会收到SIGCHLD信号,但由于处理动作被设置为忽略,父进程可以继续执行其他任务,而不需要等待子进程的退出。
通过这些方法,父进程可以更加灵活地处理子进程的退出,并更好地利用多执行流的优势,提高服务器的并发处理能力和效率。
多级创建子进程
我让服务端创建出来的子进程再次进行fork,让孙子进程为客户端提供服务, 此时我们就不用等待孙子进程退出了。
- 父进程:在服务端调用accept函数获取客户端连接请求的进程。
- 子进程:由父进程调用fork函数创建出来的进程。
- 孙子进程:由子进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务。
我们让子进程创建完孙子进程后立刻退出,此时服务进程(父进程)调用wait/waitpid函数等待子进程就能立刻等待成功,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。
为什么不需要等待孙子进程退出?
由于子进程在创建孙子进程后立即退出,孙子进程会变成孤儿进程。在这种情况下,系统会接管对孙子进程的管理。当孙子进程为客户端提供完服务并退出时,系统会回收相关资源。此外,每个父进程只需关注自己的子进程,不需要等待孙子进程的退出。因此,服务进程(父进程)可以更加高效地处理客户端请求,而不需要等待子进程或孙子进程的退出。
关闭对应的文件描述符
当服务进程(父进程)调用accept函数获取到新连接时,它会通过子进程来创建孙子进程,并由孙子进程为该连接提供服务。在此过程中,服务进程、子进程和孙子进程各自的文件描述符表是独立的,不会相互影响。
由于服务进程在调用fork函数后创建了子进程,它不再需要关心从accept函数获取的文件描述符。因此,服务进程可以调用close函数关闭该文件描述符,释放相关资源。
同样地,子进程和孙子进程也不需要关心从服务进程继承的监听套接字。因此,子进程可以选择关闭这个监听套接字,以释放资源并确保系统的正常运行。
这种设计方式充分利用了多执行流的优势,使得服务进程、子进程和孙子进程可以并行处理不同的任务,提高服务器的并发处理能力和效率。同时,独立的文件描述符表和资源管理确保了系统的稳定性和可靠性。
关闭文件描述符的必要性:
- 对于服务进程来说,当它调用fork函数后,必须关闭从accept函数获取的文件描述符。这是因为服务进程会持续地调用accept函数来获取新的文件描述符(服务套接字)。如果服务进程不及时关闭不再使用的文件描述符,会导致可用文件描述符逐渐减少,从而影响到服务器的正常运作。
- 对于子进程和孙子进程来说,建议关闭从服务进程继承下来的监听套接字。实际上,即使它们不关闭监听套接字,只会造成该文件描述符的泄漏,但通常还是建议关闭。因为孙子进程在提供服务时可能会对监听套接字进行误操作,这可能会对监听套接字中的数据造成影响。
因此,为了确保服务器的稳定性和可靠性,及时关闭不再使用的文件描述符是必要的。同时,关闭继承自服务进程的监听套接字也是为了避免潜在的误操作和数据影响。
class TcpServer
{
public:
TcpServer(const uint16_t& serverport,const string& ip = defaultip)
:listensock_(defaultfd),
port_(serverport),
ip_(ip)
{}
void Start()
{
lg(Info,"tcpServer is running...");
for(;;)
{
//1.获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
if(sockfd < 0)
{
lg(Warning,"accept error, errno: %d ,errstring: %s", errno, strerror(errno));
continue;//TCP服务器不会因为某个连接获取失败而终止。因此,当服务端获取连接失败时,应该继续尝试获取其他连接。
}
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(clientip));
lg(Info, "get a new link, listensock_: %d ,%s, %d", sockfd, clientip, clientport);
//2.根据新链接来进行通信
// version 1 -- 单进程版
// Service(sockfd,clientip,clientport);
// close(sockfd);
// version 2 -- 多进程版
pid_t id = fork();//创建子进程
if(id == 0)//子进程
{
//child
close(listensock_);
if(fork() > 0) exit(0);//创建孙子进程,子进程直接退出,父进程就不用阻塞等待
Service(sockfd,clientip,clientport);//孙子进程进行服务, system 领养
close(sockfd);
exit(0);//孙子进程提供完服务退出
}
//父进程
close(sockfd);//father关闭为连接提供服务的套接字
pid_t rid = waitpid(id,nullptr,0);//等待子进程(会立刻等待成功)
}
}
~TcpServer()
{
if(listensock_ >= 0) close(listensock_);
}
private:
int listensock_;//监听套接字
uint16_t port_;//端口号
string ip_;
};
服务器测试
重新编译程序运行客户端后,继续使用监控脚本对服务进程进行实时监控。
while :; do ps axj | head -1 && ps axj | grep tcpserver | grep -v grep;echo "######################";sleep 1;done
我们看到一开始没有客户端连接服务器,因此也是只监控到了一个服务进程,该服务进程正在等待客户端的请求连接。
然后我们运行一个客户端,让该客户端连接当前这个服务器,此时服务进程会创建出子进程,子进程再创建出孙子进程,之后子进程就会立刻退出,而由孙子进程为客户端提供服务。因此这时我们只看到了两个服务进程,其中一个是一开始用于获取连接的服务进程,还有一个就是孙子进程,该进程为当前客户端提供服务,它的PPID为1,表明这是一个孤儿进程。
当我们运行第二个客户端连接服务器时,此时就又会创建出一个孤儿进程为该客户端提供服务。我们看到两个进程的文件描述符都是4。因为父进程将子进程的文件描述符关闭了。
此时,这两个客户端是由两个独立的孤儿进程提供服务的。由于它们运行在不同的进程中,因此可以同时接收服务,不会互相干扰。可以看到,客户端发送给服务端的数据都能在服务端得到输出,并且服务端会根据这些数据进行相应的响应。这种设计方式充分利用了多进程的并发处理能力,提高了服务器的效率和性能,确保了多个客户端能够同时享受到服务。
当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的服务进程。
捕捉SIGCHLD信号
在实际操作中,当子进程退出时,它会向父进程发送SIGCHLD信号。如果父进程捕捉到这个信号,并将其处理动作设置为忽略,那么父进程就可以专注于自己的工作,而不必过分关注子进程的状态。
该方式实现起来非常简单,也是比较推荐的一种做法。
class TcpServer
{
public:
TcpServer(const uint16_t& serverport,const string& ip = defaultip)
:listensock_(defaultfd),
port_(serverport),
ip_(ip)
{}
void Start()
{
signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信号
lg(Info,"tcpServer is running...");
for(;;)
{
//1.获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
if(sockfd < 0)
{
lg(Warning,"accept error, errno: %d ,errstring: %s", errno, strerror(errno));
continue;//TCP服务器不会因为某个连接获取失败而终止。因此,当服务端获取连接失败时,应该继续尝试获取其他连接。
}
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(client));
lg(Info, "get a new link, listensock_: %d ,%s, %d", sockfd, clientip, clientport);
//2.根据新链接来进行通信
// version 2 -- 多进程版
//捕捉SIGCHLD信号版
pid_t id = fork();//创建子进程
if(id == 0)//子进程
{
close(listensock_);
Service(sockfd,clientip,clientport);
exit(0);
}
}
}
~TcpServer()
{
if(listensock_ >= 0) close(listensock_);
}
private:
int listensock_;//监听套接字
uint16_t port_;//端口号
string ip_;
};
代码测试
重新编译程序运行服务端后,可以通过以下监控脚本对服务进程进行监控。
while :; do ps axj | head -1 && ps axj | grep tcpserver | grep -v grep;echo "######################";sleep 1;done
当客户端连接到服务器时,服务进程会通过调用fork函数来创建一个子进程,该子进程专门负责为该客户端提供服务。这种设计充分利用了多执行流的优势,使得多个客户端可以同时获得服务,而不会相互干扰。
如果又有新的客户端连接至服务器,服务进程会再次调用fork函数创建另一个子进程,以确保新客户端也能获得独立的服务。这两个客户端由不同的子进程提供服务,这意味着它们可以同时享受服务,并且发送给服务端的数据都能得到输出和响应。
测试结果如下:
此时我们运行一个客户端,让该客户端连接服务器,此时服务进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务。
如果再有一个客户端连接服务器,此时服务进程会再创建出一个子进程,让该子进程为这个客户端提供服务。
当客户端一个个退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接。
多线程的TCP网络程序
创建进程涉及到多个重要的数据结构,如进程控制块(task_struct)、进程地址空间(mm_struct)和页表等,这些结构的创建和维护都需要一定的资源和时间。相比之下,创建线程的成本较低,因为线程是在进程地址空间内运行的,可以共享进程的大部分资源。
在实现多执行流的服务器时,使用多线程技术是一个更好的选择。当服务进程通过调用accept函数获取到一个新连接时,可以直接创建一个线程,由该线程为客户端提供服务。
值得注意的是,虽然主线程(服务进程)创建了新线程,但仍然需要等待新线程退出,否则可能会导致类似于僵尸进程的问题。然而,对于线程来说,如果不想让主线程等待新线程退出,可以选择调用pthread_detach函数来实现线程分离。这样,当该线程退出时,系统会自动回收其资源。
通过这种设计,主线程(服务进程)可以继续调用accept函数来获取新连接,同时新线程可以为对应的客户端提供服务。这种多线程的实现方式能够更好地利用系统资源,提高服务器的并发处理能力和效率。
各个线程共享同一张文件描述符表
文件描述符表是用于维护进程与文件之间对应关系的数据结构。每个进程都有自己独立的文件描述符表,因此一个进程对应一张文件描述符表。当主线程(服务进程)创建新线程时,新线程仍然属于同一个进程。这意味着在创建线程时,不会为新线程创建独立的文件描述符表。所有的线程共享同一张文件描述符表,这意味着它们可以访问和操作同一个文件描述符集合。
在服务器中,当服务进程(主线程)通过调用accept函数获取到一个文件描述符后,新创建的线程可以直接访问和使用这个文件描述符。这是因为所有线程共享同一个文件描述符表,它们能够访问相同的文件描述符集合。
重要的是要注意,虽然新线程可以访问主线程通过accept函数获取的文件描述符,但新线程并不知道它所服务的客户端对应的文件描述符。因此,主线程在创建新线程后,需要明确告诉每个新线程应该访问的文件描述符的值。这确保了每个新线程在为特定客户端提供服务时,能够准确地操作正确的套接字。
参数结构体
当新线程为客户端提供服务时,它需要调用Service函数,并向其传递三个关键参数:客户端的套接字、IP地址和端口号。为了确保每个线程都能为正确的客户端提供服务,主线程在创建新线程时需要传递这些参数。
然而,pthread_create函数在创建新线程时,只能接受一个类型为void*的参数。为了解决这个问题,我们可以设计一个参数结构体Param,将这三个参数封装到该结构体中。
主线程在创建新线程时,可以定义一个Param对象,将客户端的套接字、IP地址和端口号初始化到该对象中。然后,将Param对象的地址作为参数传递给新线程的执行例程。
在新线程的执行例程中,可以将这个void类型的参数强制转换为Param类型,从而获得客户端的套接字、IP地址和端口号。这样,新线程就能够正确地调用Service函数,为相应的客户端提供服务。
class ThreadData
{
public:
ThreadData(int fd,const string& ip,const uint16_t port)
:sockfd(fd),
clientip(ip),
clientport(port)
{}
public:
int sockfd;
string clientip;
uint16_t clientport;
};
文件描述符关闭的问题
由于所有线程共享同一张文件描述符表,因此在操作文件描述符表时,需要考虑线程间的协同工作。
- 对于主线程通过accept函数获取的文件描述符,主线程不应该直接进行关闭操作。这是因为新线程负责为客户端提供服务,因此只有在新线程完成服务后,才应该关闭该文件描述符。这样做可以确保资源得到正确的释放,并避免因线程间的竞态条件而导致的错误操作。
- 另外,虽然新线程不需要关注监听套接字,但同样不能关闭监听套接字对应的文件描述符。因为关闭监听套接字会导致主线程无法从该套接字中接收新的连接请求。
参数结构体增加TcpServer类的成员变量
- 由于调用pthread_create函数创建线程时,新线程的执行例程是一个参数为void*,返回值为void*的函数。如果我们要将这个执行例程定义到类内,就需要将其定义为静态成员函数,否则这个执行例程的第一个参数是隐藏的this指针。
- 在线程的执行例程当中会调用Service函数,由于执行例程是静态成员函数,静态成员函数无法调用非静态成员函数。因此我们在参数结构体中定义一个TcpServer类的变量,这样执行例程就可以调用TcpServer类中Service函数。(我们也可以将Service函数定义为静态成员函数。恰好Service函数内部进行的操作都是与类无关的,因此我们直接在Service函数前面加上一个static即可。)
class TcpServer;//ThreadData要用到,提前声明
class ThreadData
{
public:
ThreadData(int fd,const string& ip,const uint16_t port, TcpServer *t)
:sockfd(fd),
clientip(ip),
clientport(port),
tsvr(t)
{}
public:
int sockfd;
string clientip;
uint16_t clientport;
TcpServer *tsvr;
};
class TcpServer
{
public:
TcpServer(const uint16_t& serverport,const string& ip = defaultip)
:listensock_(defaultfd),
port_(serverport),
ip_(ip)
{}
//version 3 -- 多线程版本
static void* Rountine(void* args)
{
pthread_detach(pthread_self());
ThreadData* td = static_cast<ThreadData*>(args);
td->tsvr->Service(td->sockfd,td->clientip,td->clientport);
delete td;
return nullptr;
}
void Start()
{
lg(Info,"tcpServer is running...");
for(;;)
{
//1.获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
if(sockfd < 0)
{
lg(Warning,"accept error, errno: %d ,errstring: %s", errno, strerror(errno));
continue;//TCP服务器不会因为某个连接获取失败而终止。因此,当服务端获取连接失败时,应该继续尝试获取其他连接。
}
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(clientip));
lg(Info, "get a new link, listensock_: %d ,%s, %d", sockfd, clientip, clientport);
//2.根据新链接来进行通信
//version 3 -- 多线程版本
ThreadData* td = new ThreadData(sockfd,clientip,clientport,this);
pthread_t tid;
pthread_create(&tid,nullptr,Rountine,td);
}
}
void Service(int sockfd,const string& clientip,const uint16_t& clientport)
{
char buffer[4096];
while (true)
{
ssize_t n = read(sockfd,buffer,sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
cout << "client say#" << buffer << endl;
string echo_string = "tcpserver echo# ";
echo_string += buffer;
write(sockfd,echo_string.c_str(),echo_string.size());
}
else if(n == 0)
{
lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
lg(Warning,"read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
break;
}
}
}
~TcpServer()
{
if(listensock_ >= 0) close(listensock_);
}
private:
int listensock_;//监听套接字
uint16_t port_;//端口号
string ip_;
};
代码测试
此时我们再重新编译服务端代码,由于代码当中用到了多线程,因此编译时需要携带上-pthread选项。此外,由于我们现在要监测的是一个个的线程,因此在监控时使用的不再是ps -axj命令,而是ps -aL命令。
while :; do ps -aL|head -1&&ps -aL|grep tcpserver;echo "####################";sleep 1;done
运行服务端,通过监控可以看到,此时只有一个服务线程,该服务线程就是主线程,它现在在等待客户端的连接到来。
当客户端连接到服务端时,主线程会为该客户端构建一个参数结构体。接着,主线程会创建一个新线程,并将参数结构体的地址作为参数传递给新线程。
通过这种方式,新线程能够访问参数结构体并从中提取出所需的参数。新线程使用这些参数调用Service函数,为特定的客户端提供服务。
在监控过程中,由于主线程和新线程同时运行,因此会显示两个线程。主线程负责监听新的连接请求,而新线程则负责处理特定客户端的服务请求。
当第二个客户端的连接请求到达服务端时,主线程会执行与第一个客户端连接时相同的操作。它会为第二个客户端构建一个新的参数结构体,并再次创建一个新线程。这个新线程将使用从参数结构体中提取的信息,为第二个客户端提供所需的服务。
由于每个客户端都有各自的服务线程为其提供服务,这意味着服务端为这两个客户端提供了独立的执行流。这使得两个客户端可以同时接收服务端的响应,并享受服务端提供的并行服务。
当这两个客户端向服务端发送消息时,这些消息可以在服务端被正确地打印出来。同样,客户端也能收到服务端返回的回显数据。
无论客户端的连接请求有多少,服务端都会动态地创建相应数量的新线程。当客户端完成与服务端的交互并退出时,为其提供服务的新线程也会相应地结束任务并退出。这意味着服务端的线程数量会随着客户端的连接和断开而动态变化。
最终,当所有客户端都断开连接后,服务端将只剩下最初的主线程在运行。主线程会持续监听新的连接请求,随时准备新连接的到来。
线程池的TCP网络程序
多线程版本存在的问题:当大量的客户端连接到服务器时,服务器会为每个客户端创建一个新的服务线程。然而,这种方法存在几个问题:
- 资源消耗:每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,
- 上下文切换开销:当线程数量过多时,CPU在切换线程时需要进行上下文切换,这需要花费一定的时间。频繁的上下文切换会导致线程响应变慢,影响服务器的性能。
- 线程管理开销:每个线程都需要被适当地管理和调度,这需要一定的计算和存储开销。当线程数量过多时,线程管理的复杂性也会增加,这可能导致性能下降。
- 服务质量不稳定:一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答。
针对上述问题,我们可以采取以下解决思路:
1. 线程池技术
预先在服务器端创建一定数量的线程,形成一个线程池。当有新的客户端连接请求时,线程池中的线程可以立即为客户端提供服务,避免了为每个客户端单独创建线程的开销和延迟。
2. 线程复用
当一个线程完成对某个客户端的服务后,它并不会被销毁,而是继续为下一个客户端提供服务。这样可以大大减少线程的创建和销毁开销,提高服务器的处理效率。
3. 线程休眠与唤醒
在没有客户端请求时,线程可以进入休眠状态,以降低CPU的负载。当有新的客户端请求到来时,休眠的线程可以被唤醒并立即为客户端提供服务。
4. 连接队列
当所有线程都在为其他客户端提供服务,而又有新的客户端连接请求到来时,可以将这些请求放入连接队列中等待。一旦有线程空闲出来,就可以从队列中取出请求并为其提供服务。
通过上述方法,可以有效解决单纯多线程服务器存在的问题,提高服务器的并发处理能力和性能。
引入线程池
为了解决上述问题,我们需要在服务端引入线程池。线程池的目的是降低处理短时间任务时创建和销毁线程的开销,并确保内核资源的充分利用,防止过度调度。
线程池的核心组件是任务队列。当新的任务到来时,它会被推送到任务队列中。线程池预先创建了若干个线程,这些线程会不断检查任务队列,看是否有待处理的任务。一旦发现任务,线程会取出任务并调用其Run函数进行处理。如果没有任务,线程会进入休眠状态,等待新的任务到来。
(我在前面写过一篇线程池的博客,大家可以去看一下,这里我就讲解线程池接入的方法。)
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
struct ThreadInfo
{
pthread_t tid;
string name;
};
static const int defaultnum = 5;
//线程池
template<class T>
class ThreadPool
{
public:
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 task_.empty();
}
string GetThreadName(pthread_t tid)
{
int num = threads_.size();
for(int i = 0;i < num;++i)
{
if(threads_[i].tid == tid)
return threads_[i].name;
}
return "none";
}
public:
//线程池中线程的执行例程
static void* HandlerTask(void* args)
{
ThreadPool<T>* tp = static_cast<ThreadPool<T> *>(args);//?
string name = tp->GetThreadName(pthread_self());
//不断从任务队列获取任务进行处理
while (true)
{
//消费任务
tp->Lock();
while (tp->IsQueueEmpty())//任务队列为空
{
tp->ThreadSleep();
}
T t = tp->Pop();
tp->UnLock();
t();//处理任务
}
}
void Start()
{
int num = threads_.size();
for(int i = 0;i < num;++i)
{
threads_[i].name = "thread-" + to_string(i);
//pthread_create要求HandlerTask函数是void*返回值,参数void*类型,如果HandlerTask函数直接定义在类里面,那么参数还会有一个隐藏的this
//指针,类型就不匹配了,编译会出现错误,所以我们将HandlerTask定义为static函数,然后再将this指针通过pthread_create参数传入进去,就可以避免出现这样的问题
pthread_create(&(threads_[i].tid),nullptr,HandlerTask,this);
}
}
//往任务队列塞任务(主线程调用)
void Push(const T&t)
{
Lock();
task_.push(t);
Wakeup();
UnLock();
}
//从任务队列获取任务(线程池中的线程调用)
T Pop()
{
T t = task_.front();
task_.pop();
return t;
}
static ThreadPool<T>* GetInstance()
{
if(tp_ == nullptr)//tp_被创建了,就直接返回tp_,不需要让其他线程再去申请锁
{
pthread_mutex_lock(&lock_);//防止多个线程获取单例,加上锁防止被new多次
if(tp_ == nullptr)
{
cout << "log: singleton create done first!" << endl;
tp_ = new ThreadPool<T>();
}
pthread_mutex_unlock(&lock_);
}
return tp_;
}
private:
ThreadPool(int num = defaultnum):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;
private:
vector<ThreadInfo> threads_;//存放线程
queue<T> task_;//创建任务队列
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;
服务类新增线程池成员
我们使用的是单例模式的线程池,在服务端Start函数调用线程池中的GetInstance函数获取单例,然后对调用线程池中封装的Start函数创建线程,并指定线程池中的线程数量(不指定默认为5个线程),并完成现成的初始化。这些线程将不断从任务队列中取出任务进行处理。
当服务进程通过accept函数获得客户端连接请求时,根据客户端的套接字、IP地址和端口号构建任务,并使用线程池提供的Push接口将其添加到任务队列中。
这是一个典型的生产者-消费者模型。服务进程作为任务的生产者,而线程池中的线程作为消费者。生产者和消费者在任务队列中进行交互,线程池充当了他们之间的交易场所。通过这种方式,服务进程可以快速将任务添加到队列中,而线程池中的线程则负责高效地处理这些任务。
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "Log.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"
extern Log lg;
using namespace std;
const int defaultfd = -1;
const int backlog = 10; // 但是一般不要设置的太大
const string defaultip = "0.0.0.0";
enum
{
UsageError = 1,
SocketError,
BindError,
ListenError,
};
class TcpServer;//ThreadData要用到,提前声明
class ThreadData
{
public:
ThreadData(int fd,const string& ip,const uint16_t port, TcpServer *t)
:sockfd(fd),
clientip(ip),
clientport(port),
tsvr(t)
{}
public:
int sockfd;
string clientip;
uint16_t clientport;
TcpServer *tsvr;
};
class TcpServer
{
public:
TcpServer(const uint16_t& serverport,const string& ip = defaultip)
:listensock_(defaultfd),
port_(serverport),
ip_(ip)
{}
void InitServer()
{
//1.创建套接字
listensock_ = socket(AF_INET,SOCK_STREAM,0);
if(listensock_ < 0)
{
lg(Fatal, "create socket, errno: %d, errstring: %s", errno, strerror(errno));
exit(SocketError);
}
lg(Info, "create socket success, listensock_: %d", listensock_);
//2.绑定
struct sockaddr_in local;
bzero(&local,sizeof(0));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
// local.sin_addr = INADDR_ANY;
inet_aton(ip_.c_str(),&(local.sin_addr));//换个接口写一下
if (bind(listensock_, (struct sockaddr *)&local, sizeof(local)) < 0)
{
lg(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
exit(BindError);
}
lg(Info, "bind socket success, listensock_: %d", listensock_);
//3.Tcp是面向连接的,服务器一般是比较“被动的”,服务器一直处于一种,一直在等待连接到来的状态
if(listen(listensock_,backlog) < 0)
{
lg(Fatal,"listen error, errno: %d, errstring: %s", errno, strerror(errno));
exit(ListenError);
}
lg(Info, "listen socket success, listensock_: %d", listensock_);
}
void Start()
{
// signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信号
ThreadPool<Task>::GetInstance()->Start();
lg(Info,"tcpServer is running...");
for(;;)
{
//1.获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
if(sockfd < 0)
{
lg(Warning,"accept error, errno: %d ,errstring: %s", errno, strerror(errno));
continue;//TCP服务器不会因为某个连接获取失败而终止。因此,当服务端获取连接失败时,应该继续尝试获取其他连接。
}
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(client));
lg(Info, "get a new link, listensock_: %d ,%s, %d", sockfd, clientip, clientport);
//2.根据新链接来进行通信
// version 3 -- 多线程版本
// ThreadData* td = new ThreadData(sockfd,clientip,clientport,this);
// pthread_t tid;
// pthread_create(&tid,nullptr,Rountine,td);
// version 4 --- 线程池版本
Task t(sockfd, clientip, clientport);
ThreadPool<Task>::GetInstance()->Push(t);
}
}
~TcpServer()
{
if(listensock_ >= 0) close(listensock_);
}
private:
int listensock_;//监听套接字
uint16_t port_;//端口号
string ip_;
};
设计任务类
我们现在要设计一个任务类,该类将包含与特定客户端相关的套接字、IP地址和端口号。这些信息将用于标识该任务是为哪个客户端提供服务,以及对应的操作套接字是什么。
任务类中还应包含一个Run方法。当线程池中的线程获取任务后,它将直接调用Run方法来处理该任务。实际上,处理任务的方法是服务类中的Service函数。为了保持软件分层结构,我们不直接将Service函数放入任务类中作为Run方法。相反,我们可以为任务类添加一个仿函数成员。当执行任务类的Run方法来处理任务时,我们可以以回调的方式处理该任务。
此时需要再设计一个Handler类,在Handler类当中对()操作符进行重载,将()操作符的执行动作重载为执行Service函数的代码。
class Handler
{
public:
void operator()(int sockfd,const string& clientip,const uint16_t& clientport)
{
char buffer[4096];
while (true)
{
ssize_t n = read(sockfd,buffer,sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
cout << "client say#" << buffer << endl;
string echo_string = "tcpserver echo# ";
echo_string += buffer;
write(sockfd,echo_string.c_str(),echo_string.size());
}
else if(n == 0)
{
lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
lg(Warning,"read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
break;
}
}
}
};
class Task
{
public:
Task(int sockfd,const string& clientip,const uint16_t& clientport)
:sockfd_(sockfd),
clientip_(clientip),
clientport_(clientport)
{
}
Task()
{}
void run()
{
handler(sockfd_, clientip_, clientport_); //调用仿函数
}
void operator()()
{
run();
}
~Task()
{}
private:
int sockfd_;
uint16_t clientport_;
string clientip_;
Handler handler;
};
注意:当任务队列中有任务时,线程池中的线程会创建一个Task对象,并将其作为输出参数传递给任务队列的Pop函数,从而从队列中获取任务。为了方便创建无参的Task对象,Task类需要提供一个无参构造函数。同时,为了能够通过Pop函数将Task对象从任务队列中弹出,Task类还需要提供一个带参的构造函数。
实际上,服务器可以处理各种不同的任务,而不仅仅是简单的字符串回显。任务的具体处理方式是由任务类中的handler成员决定的。
如果需要服务器处理其他类型的任务,只需修改Handler类中对()的重载函数即可。服务器的初始化、启动和线程池的代码无需更改。这种设计方式实现了通信功能与业务逻辑的软件解耦,使得代码更加灵活和可扩展。
代码测试
此时我们再重新编译服务端代码,并用以下监控脚本查看服务端的各个线程。
while :; do ps -aL|head -1 && ps -aL| grep tcp_server;echo "####################";sleep 1;done
运行服务端后,就算没有客户端发来连接请求,此时在服务端就已经有了6个线程,其中有一个是接收新连接的服务线程,而其余的5个是线程池当中为客户端提供服务的线程。
当客户端连接到服务器时,服务端的主线程会接收连接请求,并将其封装为一个任务对象,然后将其推送到任务队列中。线程池中的5个线程会有一个线程从队列中取出该任务,并执行任务的处理函数,从而为客户端提供服务。
当第二个客户端发起连接请求时,服务端同样会将该请求封装为一个任务对象,并将其放入任务队列中。线程池中的线程会从队列中取出任务进行处理。由于每个任务由不同的执行流处理,因此两个客户端可以同时享受服务,并由不同的线程为其提供服务。这样保证了服务器的并发处理能力,使得多个客户端能够同时得到响应。
与之前的情况不同,无论现在有多少客户端发起请求,服务端只有线程池中的5个线程为其提供服务。线程池中的线程数量不会因为客户端数量的增加而增加,这些线程也不会因为客户端的断开而退出。这种设计确保了服务器的稳定性和性能,同时避免了因线程过多而导致的资源浪费和调度开销。