目录
1. 前置性知识
1.1. listen 系统调用
1.2. accept 系统调用
1.3. 如何通信
1.3.1. read 系统调用 && write系统调用
1.3.2. recv 系统调用 && send 系统调用
2. TCP --- demo
2.1. Tcp_Server.hpp (version 1)
2.2. Tcp_Server.hpp (version 2.1)
2.3. Tcp_Server.hpp (version 2.2)
2.4. Tcp_Server.hpp (version 3)
2.5. Tcp_Client.cc (version 1)
2.6. Tcp_Server.cc (version 1)
2.7. Tcp_Server.hpp (version 4)
2.7.1. Task.hpp
2.7.2. version 4 版本的 Tcp.Server.hpp
2.7.3. version 4.1 版本的 Tcp.Server.hpp
2.7.4. 与version 4.1版的服务端匹配的客户端
3. 补充问题
1. 前置性知识
1.1. listen 系统调用
int listen(int sockfd, int backlog);
因为TCP是面向连接的,因此当客户端和服务端正式通信的时候,首先需要先建立连接。而对于服务端来说, 它并不知道客户端什么时候会来连接, 因此它是不是应该保持一种等待连接的状态呢? 因此, listen这个系统调用就是将特定套接字设置为监听状态, 使其可以接受客户端的连接请求。
- sockfd:要设置为监听状态的套接字描述符。
- backlog:定义了在等待连接队列中可以排队的最大连接数。
成功返回0, 失败返回-1,并且 error 被适当地设置。
1.2. accept 系统调用
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept 函数用于从监听套接字接受连接,它会创建一个新的套接字来处理与客户端的通信。参数 sockfd 是调用 bind和 listen 后返回的套接字描述符,而参数 add r和 addrlen 用于存储客户端的地址信息。
具体来说:
- sockfd 是调用 socket、bind 和 listen 后返回的套接字描述符,sockfd 表示服务器正在监听的套接字。
- addr 是一个指向sockaddr结构体的指针,其是一个输出型参数,用于存储客户端的地址信息。这个结构体应该是用来存储与服务器通信的客户端的地址信息,但在调用accept之前,addr参数通常是指向一个用于接收客户端地址信息的缓冲区的指针。
- addrlen是一个指向socklen_t类型的指针,用于指示addr指向的地址结构体的大小。在调用accept之前,应该将其设置为sizeof(struct sockaddr) ,可以理解为输入输出型参数(输入型参数:addr的初始值大小, 输出型参数: addr的实际大小)。
成功调用 accept 后,addr 将包含客户端的地址信息,而 addrlen 将被更新为addr指向的结构体的实际大小。如果不关心客户端的地址信息,可以将addr和addrlen设置为NULL。
需要注意的是,accept 函数在接受到一个连接之前会一直阻塞,直到有连接请求到达。一旦有连接请求到达,它会返回一个新的套接字 (服务套接字),该套接字用于与客户端进行通信,而原始的监听套接字则继续用于接受其他连接请求。
fd = accpept(_sock, (struct sockaddr*)&src, &len);
为了更好地理解这两个套接字 (fd、 _sock), 我们举一个故事,用以理解:
小A同学去旅游, 到了一个景点, 正在闲逛呢, 此时来了一个人, 名叫张三, 张三说, 朋友,你是来外地的吧,第一次来这里吗? 小A说, 是的。 张三说:那你可得来尝尝咱家的手艺了, 咱家做的鱼可是远近闻名,上等佳肴啊, 你要不要来尝尝, 价格也便宜。 小A一听, 逛了挺久的,也累了, 你就答应了。 然后小A就跟着张三去了馆子, 一到馆子, 张三就说,赵四,快出来,来客人了,都招乎上。于是赵四就出来了, 然后为小A提供服务。
而此时的张三呢? 张三没有进入餐馆为小A提供服务, 而是转头就走了, 去寻找下一个顾客了。
于是张三又遇到了小B同学, 就问小B,饿不饿,想不想吃点饭,说咱家的特色菜非常好吃等等, 但是小B说, 我不饿, 我还想在逛一逛再去吃, 那么此时,这个张三怎么做呢? 难道说,张三死乞白赖的一直跟着小B? 答案是,当然不会,正常情况下, 张三一听小B不想吃,就会说你要想吃的话就来咱家,咱随时欢迎你,然后就走了,就去找下一个顾客了。
那么我们就可以通过上面的例子就可以理解这两个套接字的差别呢?
fd (赵四等提供服务的服务员) --- 服务套接字, _sock (张三 --- 获取连接请求)--- 监听套接字。
而 _sock 我们一般称之为 listen_sock, 监听套接字,它的主要功能就是获取连接请求,如果有连接请求,我们再去为这个连接请求建立连接。
而这里的 fd 。 我们称之为 service_sock, 服务套接字,即服务端是通过这个套接字和客户端进行交互的。
如果创建连接失败,那么可以处理下一个连接请求或者根据具体情况进行错误处理。
如果创建连接成功,就可以通过返回的新套接字 (服务套接字) 与客户端进行通信,而原始的监听套接字则可以继续接受其他连接请求。
1.3. 如何通信
上面我们已经解决了客户端和服务端如何建立连接的问题, 那么当客户端和服务端连接成功后, 客户端和服务端如何传输数据呢?
难道还是像UDP一样, 用 recvfrom 吗? 并不是, recvfrom 是专门用UDP协议的, 是面向数据报的, 而TCP是面向字节流的。
1.3.1. read 系统调用 && write系统调用
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
在TCP协议中, 当服务端和客户端建立连接后, 我们可以直接使用 read 从特定套接字读取数据,也可以用 write 向特定套接字写数据,因为套接字是和文件描述符强关联的。
1.3.2. recv 系统调用 && send 系统调用
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
recv 和 send 这两个函数在TCP协议中通常用于进行流式套接字的数据传输。
send 基于TCP向目标套接字发送消息。前三个参数和返回值和 write 一摸一样。flags == 0 , 代表阻塞式的发送消息 (缓冲区为满会被阻塞)。
recv 基于TCP从目标套接字获取消息。前三个参数和返回值和 read一摸一样。flags == 0 , 代表阻塞式的读取 (缓冲区为空会被阻塞)。
了解:
flags会有一些其他选项:
MSG_DONTWAIT: 表示以非阻塞方式进行发送或接收操作。如果设置了这个标志,函数将立即返回,而不会等待缓冲区可用或满。
MSG_NOSIGNAL:仅适用于send函数。表示在发送数据时不产生SIGPIPE信号,而是返回错误。这在处理已关闭的连接时很有用。
MSG_OOB:表示处理带外数据(Out-of-Band data)。TCP协议支持通过特殊的通道发送带外数据,这些数据可以在普通数据之外传输。使用此标志表示操作带外数据。
MSG_PEEK:表示查看套接字接收缓冲区的数据,但不移除数据。这可以用于检查接收缓冲区中的数据而不实际读取它。
MSG_WAITALL:表示在recv函数中,如果请求的字节数没有全部可用,那么将一直等待,直到请求的字节数全部可用为止。
2. TCP --- demo
2.1. Tcp_Server.hpp (version 1)
服务端:单进程 (单执行流) 版本。
#ifndef __TCP_SERVER_HPP_
#define __TCP_SERVER_HPP_
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "Log.hpp"
#define SERVER_BUFFER 1024
namespace Xq
{
const static int backlog = 20;
class tcp_server
{
public:
tcp_server(uint16_t port, const std::string& ip = "")
:_ip(ip), _port(port), _listen_sock(-1)
{}
void init_server()
{
// step 1: create socket
// AF_INET, 表示IPV4互联网协议
// SOCK_STREAM 表示这是面向字节流的
// 此时就表示创建一个基于IPV4地址族、面向字节流的套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_listen_sock == -1)
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(1);
}
LogMessage(DEBUG, "socket success, return listen sock: %d\n", _listen_sock);
// step 2: bind socket
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(_port);
// 服务端采用任意地址
server.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
if(-1 == bind(_listen_sock, reinterpret_cast<const struct sockaddr*>(&server), sizeof server))
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(2);
}
// 上面的过程和UDP几乎没有任何区别,
// 无非UDP是面向数据报 --- SOCK_DGRAM
// 而TCP是面向字节流的 --- SOCK_STREAM
// step 3: set listen socket
// TCP是面向连接的, 因此客户端和服务端首先要建立连接
// 而对于服务端来说, 它并不知道客户端会在什么时候发起连接请求
// 因此服务端是不是应该保持一种等待获取客户端连接请求的状态呢?
// 答案是的, 因此listen这个系统调用就是将特定套接字设置为监听状态,
// 获取客户端发起的连接请求。
// backlog: 这个数字在这里暂时不解释, 后面解释
// 一般情况下, 这个值不能太大, 也不能太小
if(listen(_listen_sock, backlog) == -1)
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(3);
}
// server init done
LogMessage(DEBUG, "server init success\n");
}
void start()
{
// 服务器永不退出
while(true)
{
// step 4: accept
// accept用于接收客服端发送的连接请求
// 那么客户端向服务端发送连接请求,
// 服务端需不需要知道客户端的地址信息呢?
// 显然需要知道, 因此accept的后两个参数
// 就是发起请求方的地址信息
struct sockaddr_in client; // 输出型参数
bzero(&client, sizeof client);
socklen_t client_len = sizeof client; // 输入输出型参数
// 另外, 如果服务端没有收到连接请求
// 那么服务端会在accept内部阻塞
// 如果有连接请求, 那么我就处理
// 连接成功, 返回一个套接字, 服务套接字
// 连接失败, 返回 -1;
int server_sock = accept(_listen_sock, reinterpret_cast<struct sockaddr*>(&client), &client_len);
if(server_sock == -1)
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(4);
}
LogMessage(DEBUG, "accept success, return server_sock: %d\n", server_sock);
// 走到这里, 说明连接成功
// 就可以进行通信了
// 交互数据, 需要服务套接字, 客户端地址信息
communicate_data(server_sock, client);
}
}
// 简单的echo服务器: 将客户端发送的数据返回给客户端
static void communicate_data(int server_sock, const struct sockaddr_in& client)
{
// 提取客户端的ip, 网络字节序 -> 主机序列
uint16_t client_port = ntohs(client.sin_port);
// 提取客户端的port, 网络字节序 -> 主机序列
std::string client_ip = inet_ntoa(client.sin_addr);
char buffer[SERVER_BUFFER] = {0};
while(true)
{
// read --- 读取客户端数据
ssize_t read_size = read(server_sock, buffer, SERVER_BUFFER - 1);
if(strcmp(buffer, "quit\n") == 0)
break;
if(read_size > 0)
{
LogMessage(DEBUG, "read client data success, data size: %d\n", read_size);
buffer[read_size] = 0;
printf("[%s][%d]: %s\n", client_ip.c_str(), client_port, buffer);
}
else if(read_size == 0)
{
// 代表客户端关闭连接了
LogMessage(NORMAL, "client switch off connect, me too\n");
break;
}
else
{
LogMessage(ERROR, "%d,%s\n", strerror(errno));
exit(5);
}
// write --- 向客户端写数据
write(server_sock, buffer, sizeof buffer);
}
}
~tcp_server() {}
private:
std::string _ip;
uint16_t _port;
int _listen_sock;
};
}
#endif
由于我们目前没有写客户端, 因此我们可以用 telnet 工具,远程连接我们的服务器, 现象如下:
telnet 向服务端发送消息, 服务端将消息返回给telnet。
如果要退出 telnet 的连接, 那么先 Ctrl + ], 然后 telnet 会自动弹出 telnet>, 此时我们再输入quit, 那么 telent 就会退出,此时连接也会中断。
可是这样会存在问题, 现象如下:
上面的窗口先进行连接,连接成功。 而下面的窗口此时却没有成功连接,这是为什么呢?
原因是因为:我们上面的服务端是单进程 (单执行流),当服务端在执行 communicate_data 时, 由于 communicate_data 是一个死循环, 此时服务端就一直在和上面的 telnet 进程交互数据, 因此此时下面的 telnet 进程的连接请求就被阻塞了。
因此,单进程 (单执行流) 版本是明显有缺陷的, 因此我们想将它更改为多进程版本。
2.2. Tcp_Server.hpp (version 2.1)
服务端:多进程版本。
思路: 当 accept 获取连接请求,并成功连接后,创建子进程来处理客户端连接 (子进程通过服务套接字和客户端交互),通信结束, 子进程退出。 父进程继续使用监听套接字获取客户端请求,并重复上述过程。
不过,在编码之前,我们要提出几个问题:
问题一: 创建子进程, 让子进程为新的连接提供服务, 那么子进程能不能获得父进程曾经打开的文件描述符呢?
答案: 当然可以, 父进程创建子进程时,子进程会以父进程为模板创建相应的内核数据结构,而我们以前学习过, 进程的PCB里面有一个成员为 struct files_struct* fs,其指向一张表,这张表里有一个指针数组,类型为 struct file* fd_array, 而文件描述符就是这个数组的下标,因此, 当父进程创建子进程时, 子进程也会以写实拷贝的方式获得这张表,这意味着子进程和父进程最初共享相同的文件描述符表,即会获得相应的文件描述符。
问题二:多进程版的服务端本质上想为多个客户端提供服务,换言之, 会有多个子进程被创建,而子进程退出会有僵尸问题, 如何处理子进程的僵尸问题呢?
方案一: 以非阻塞式的 waitpid 进行等待子进程退出, 可是这种方案太麻烦, 因为我们还要记录每个进程的PID, 不太好。
方案二: 对SIGCHLD信号进行自定义捕捉 (signal函数),将动作设置为 SIG_IGN。子进程退出时,内核会立即回收子进程的资源,而不会留下僵尸进程。
方案三: 创建子进程之后,在创建子进程 (在这里称之为孙子进程,方便描述),孙子进程区处理客户端连接, 并终止子进程,父进程等待子进程退出, 此时的孙子进程就会成为孤儿进程, 被操作系统领养, 因此我们不用过多处理孙子问题的僵尸问题。
问题三:子进程或者孙子进程是为客户端提供服务的, 那么有没有必要需要知道监听套接字呢?同理, 父进程是为了获取客户端连接请求的, 那么需不需要知道服务套接字呢?
答案是:不需要, 对于子进程或者孙子进程而言,只需要知道服务套接字即可。而对于父进程而言,只需要知道监听套接字即可 (获取连接请求)。 因此我们可以关闭进程所不需要的套接字, 对于前者,我们关闭监听套接字; 对于后者,我们关闭服务套接字。
最后一个问题:如果父进程, 关闭了服务套接字, 那么影不影响子进程呢?
答案是:当然不影响咯, 因为进程具有独立性 (独立的地址空间、文件描述符表等等), 会以写实拷贝的方案保证自己的独立性。
有了上面的理解, 这就好处理了, 服务端的多进程版本如下:
#ifndef __TCP_SERVER_HPP_
#define __TCP_SERVER_HPP_
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>
#include "Log.hpp"
#define SERVER_BUFFER 1024
namespace Xq
{
const static int backlog = 20;
class tcp_server
{
public:
tcp_server(uint16_t port, const std::string& ip = "")
:_ip(ip), _port(port), _listen_sock(-1)
{}
void init_server()
{
// step 1: create socket
// AF_INET, 表示IPV4互联网协议
// SOCK_STREAM 表示这是面向字节流的
// 此时就表示创建一个基于IPV4地址族、面向字节流的套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_listen_sock == -1)
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(1);
}
LogMessage(DEBUG, "socket success, return listen sock: %d\n", _listen_sock);
// step 2: bind socket
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(_port);
// 服务端采用任意地址
server.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
if(-1 == bind(_listen_sock, reinterpret_cast<const struct sockaddr*>(&server), sizeof server))
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(2);
}
// 上面的过程和UDP几乎没有任何区别,
// 无非UDP是面向数据报 --- SOCK_DGRAM
// 而TCP是面向字节流的 --- SOCK_STREAM
// step 3: set listen socket
// TCP是面向连接的, 因此客户端和服务端首先要建立连接
// 而对于服务端来说, 它并不知道客户端会在什么时候发起连接请求
// 因此服务端是不是应该保持一种等待获取客户端连接请求的状态呢?
// 答案是的, 因此listen这个系统调用就是将特定套接字设置为监听状态,
// 获取客户端发起的连接请求。
// backlog: 这个数字在这里暂时不解释, 后面解释
// 一般情况下, 这个值不能太大, 也不能太小
if(listen(_listen_sock, backlog) == -1)
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(3);
}
// server init done
LogMessage(DEBUG, "server init success\n");
}
void start()
{
// 子进程退出, 自动被操作系统回收, 避免僵尸问题
signal(SIGCHLD, SIG_IGN);
// 服务器永不退出
while(true)
{
// step 4: accept
// accept用于接收客服端发送的连接请求
// 那么客户端向服务端发送连接请求,
// 服务端需不需要知道客户端的地址信息呢?
// 显然需要知道, 因此accept的后两个参数
// 就是发起请求方的地址信息
struct sockaddr_in client; // 输出型参数
bzero(&client, sizeof client);
socklen_t client_len = sizeof client; // 输入输出型参数
// 另外, 如果服务端没有收到连接请求
// 那么服务端会在accept内部阻塞
// 如果有连接请求, 那么我就处理
// 连接成功, 返回一个套接字, 服务套接字
// 连接失败, 返回 -1;
int server_sock = accept(_listen_sock, reinterpret_cast<struct sockaddr*>(&client), &client_len);
if(server_sock == -1)
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(4);
}
LogMessage(DEBUG, "accept success, return server_sock: %d\n", server_sock);
// 走到这里, 说明连接成功
// 就可以进行通信了
// 我们不想让父进程自己去处理客户端连接
// 而是通过父进程创建的子进程去处理客户端连接
pid_t id = fork();
if(id == -1)
{
LogMessage(FATAL, "%s,%d\n", strerror(errno));
exit(6);
}
if(id == 0)
{
LogMessage(DEBUG, "create child process success, child id: %d\n", getpid());
// child process
// 子进程不需要监听套接字
close(_listen_sock);
// 处理客户端连接
communicate_data(server_sock, client);
// 处理完后, 子进程退出
exit(0);
}
// 父进程不需要知道服务套接字
close(server_sock);
// 父进程继续去监听客户端请求, 并对客户端请求进行连接
// 因此,当新的客户端请求过来时, 父进程不会被阻塞
}
}
// 简单的echo服务器: 将客户端发送的数据返回给客户端
static void communicate_data(int server_sock, const struct sockaddr_in& client)
{
// 提取客户端的ip, 网络字节序 -> 主机序列
uint16_t client_port = ntohs(client.sin_port);
// 提取客户端的port, 网络字节序 -> 主机序列
std::string client_ip = inet_ntoa(client.sin_addr);
char buffer[SERVER_BUFFER] = {0};
while(true)
{
// read --- 读取客户端数据
ssize_t read_size = read(server_sock, buffer, SERVER_BUFFER - 1);
if(read_size > 0)
{
LogMessage(DEBUG, "read client data success, data size: %d\n", read_size);
buffer[read_size] = 0;
printf("[%s][%d]: %s\n", client_ip.c_str(), client_port, buffer);
}
else if(read_size == 0)
{
// 代表客户端关闭连接了
LogMessage(NORMAL, "client switch off connect, me too\n");
break;
}
else
{
LogMessage(ERROR, "%d,%s\n", strerror(errno));
exit(5);
}
// write --- 向客户端写数据
write(server_sock, buffer, sizeof buffer);
}
}
~tcp_server() {}
private:
std::string _ip;
uint16_t _port;
int _listen_sock;
};
}
#endif
2.3. Tcp_Server.hpp (version 2.2)
上面我们是通过 signal 函数对 SIGCHLD 信号进行显式的忽略 (SIG_IGN),解决了子进程的僵尸问题, 可是如果我非要让你通过 waitpid 来解决僵尸问题, 并且这个 waitpid 是阻塞式的等待子进程退出, 且我们要求要满足多个客户端访问 (父进程还不能被阻塞住), 该如何处理?
由于大部分对吗都和 version 2.1 的一样,我们只是更改处理僵尸问题的逻辑, 因此大部分相同的代码我省略,代码如下:
namespace Xq
{
class tcp_server
{
public:
tcp_server(uint16_t port, const std::string& ip = "")
:_ip(ip), _port(port), _listen_sock(-1){}
void init_server() { /*省略*/}
void start()
{
// 子进程退出, 自动被操作系统回收, 避免僵尸问题
signal(SIGCHLD, SIG_IGN);
// 服务器永不退出
while(true)
{
// accept 过程省略
// 走到这里, 说明连接成功
// 就可以进行通信了
// 我们不想让父进程自己去处理客户端连接
// 而是通过父进程创建的子进程的子进程 (也就是孙子进程) 去处理客户端连接
pid_t id = fork();
if(id == 0)
{
// 子进程不需要监听套接字
close(_listen_sock);
// 子进程
if(fork() > 0)
{
// 子进程直接退出, 让孙子进程成为孤儿进程
exit(0);
}
// 孙子进程处理客户端连接
communicate_data(server_sock, client);
// 由于子进程已经退出, 那么这个孙子进程就成为了一个孤儿进程, 被OS领养
// 因此当它退出时, 会自动被操作系统回收, 解决了僵尸问题
exit(0);
}
// 父进程不需要服务套接字
close(server_sock);
// 子进程一退出, 父进程就会回收其资源, 解决了僵尸问题
// 因为对于父进程而言, 子进程几乎是一瞬间退出的
// 因此, 父进程不会在这里一直阻塞
// 也就会处理其他客户端的连接请求
waitpid(id, nullptr, 0);
}
}
// 简单的echo服务器: 将客户端发送的数据返回给客户端
static void communicate_data(int server_sock, const struct sockaddr_in& client)
{ /*省略*/ }
~tcp_server() {}
private:
std::string _ip;
uint16_t _port;
int _listen_sock;
};
}
#endif
2.4. Tcp_Server.hpp (version 3)
可是,我们知道,创建一个进程成本是比较高的,例如,我们要创建进程的PCB、地址空间、页表、维护各种映射关系,调度关系等等,而我们是学习过线程的,线程和进程相比,线程就更轻量化,创建和删除的成本更低, 维护成本也更低,那么我们能不能实现一个多线程版本呢? 当然可以。
namespace Xq
{
// 线程所需要的参数
class data_t
{
public:
int _server_sock;
struct sockaddr_in _client_addr;
};
class tcp_server
{
private:
// 线程的回调
static void* start_routine(void* arg)
{
// 这里有两个问题:
// 问题一: 线程执行完如何处理呢?
// 难道让主线程(进程) 阻塞等待(pthread_join) 吗
// 很明显, 我们不能让主线程进行阻塞等待
// 因为我们需要进程一直accept客户端请求
// 那么如何处理?
// 我们可以用 pthread_detach 线程分离
// 让线程执行完后自动释放其资源, 避免了内存泄漏
pthread_detach(pthread_self());
// 第二个问题: 对于新线程而言, 需不需要关闭特定的文件描述符呢?
// 不需要, 因为进程是承担资源的基本实体, 而线程的资源是依赖于进程的.
// 因此这里的文件描述符不能关,因为它是属于这个进程的。
// 不是属于你这个线程的,你只是和这个进程共享罢了。
// 解决了上面的问题, 我们就可以处理客户端连接了
// 我们不是有一个 communicate_data 函数吗?
// 我们只需要调用它就可以处理客户端连接了
// 而communicate_data 需要服务套接字, 需要客户端的地址信息
data_t* Tdata = static_cast<data_t*>(arg);
communicate_data(Tdata->_server_sock, Tdata->_client_addr);
// 新线程执行完客户端连接, 就关闭掉服务套接字
close(Tdata->_server_sock);
delete Tdata;
return nullptr;
}
public:
tcp_server(uint16_t port, const std::string& ip = "")
:_ip(ip), _port(port), _listen_sock(-1) {}
void init_server() { /*省略*/ }
void start()
{
// 省略...
// 走到这里, 说明连接成功
// 就可以进行通信了
// 我们采用多线程的方案
// 让新线程处理客户端连接
pthread_t tid;
data_t* Tdata = new data_t;
Tdata->_server_sock = server_sock;
Tdata->_client_addr = client;
pthread_create(&tid, nullptr, start_routine, static_cast<void*>(Tdata));
}
}
// 简单的echo服务器: 将客户端发送的数据返回给客户端
static void communicate_data(int server_sock, const struct sockaddr_in& client)
{ /*省略*/ }
~tcp_server() {}
private:
std::string _ip;
uint16_t _port;
int _listen_sock;
};
}
#endif
2.5. Tcp_Client.cc (version 1)
上面版本相应的客户端,用以测试。
#include "Log.hpp"
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
const int CLIENT_BUFFER = 1024;
void Usage(void)
{
printf("please usa ./tcp_client server_ip server_port\n");
exit(1);
}
int main(int arg, char** argv)
{
if(arg != 3)
{
Usage();
}
// step 1: create socket
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock == -1)
{
LogMessage(FATAL, "%s,%d\n",strerror(errno));
exit(2);
}
// 获取服务端port, 并将其转为网络字节序
int server_port = htons(atoi(argv[2]));
// 获取服务端IP, 并将其转为网络字节序
uint32_t server_ip = inet_addr(argv[1]);
// 填充服务端地址信息
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_addr.s_addr = server_ip;
server.sin_port = server_port;
socklen_t server_len = sizeof(server);
// 客户端不需要显示bind
// 当然也不需要listen, 因为客户端是向服务端发起连接的
// 自然也不需要accept
// 那么客户端需要做什么呢?
// 客户端最需要的是连接别人的能力, 如何连接呢?
if(connect(sock, reinterpret_cast<const struct sockaddr*>(&server), server_len) == -1 )
{
LogMessage(FATAL, "client connect failed\n");
exit(3);
}
LogMessage(DEBUG, "client connect success\n");
// 如果connect成功代表着该套接字(sock)
// 与这个地址信息强关联的进程(服务端进程)建立了连接
// 此时操作系统就会自动为客户端绑定
// 接下来, 客户端就可以和服务端交互数据了
while(true)
{
std::cout << "请输入信息: " << std::endl;
std::string message;
getline(std::cin, message);
if(message == "quit")
{
LogMessage(DEBUG, "client quit\n");
break;
}
// 客户端向服务端发送数据 --- send
ssize_t real_send_size = send(sock, message.c_str(), message.size(), 0);
if(real_send_size > 0)
{
LogMessage(DEBUG, "send success\n");
}
else if(real_send_size == 0)
{
LogMessage(WARNING, "server disconnect\n");
break;
}
else
{
LogMessage(ERROR, "send failed\n");
break;
}
// 客户端收数据--- recv
char buffer[CLIENT_BUFFER] = {0};
ssize_t real_recv_size = recv(sock, buffer, CLIENT_BUFFER - 1, 0);
if(real_recv_size > 0)
{
buffer[real_recv_size] = 0;
printf("server: %s\n", buffer);
}
else
{
LogMessage(ERROR, "recv error\n");
break;
}
}
return 0;
}
2.6. Tcp_Server.cc (version 1)
上面版本相应的服务端,用以测试。
#include <memory>
#include <iostream>
#include "Tcp_Server.hpp"
void Usage(void)
{
printf("please use ./tcp_server port\n");
exit(6);
}
int main(int arg, char* argv[])
{
if(arg != 2)
{
Usage();
}
uint16_t port = atoi(argv[1]);
std::unique_ptr<Xq::tcp_server> server(new Xq::tcp_server(port));
server->init_server();
server->start();
return 0;
}
2.7. Tcp_Server.hpp (version 4)
可是,如果客户端请求很多, 难道每来一个客户端请求我才去创建一个线程吗,然后线程执行完,就销毁这个线程,这样是不是会导致频繁的创建和销毁?这样虽然可以,但是效率有点低, 因此为了提高效率以及减少线程创建和销毁的开销,我们可不可以提前创建一批线程, 当客户端请求来的时候, 我在去这批线程找一个线程让它帮助我处理这个客户端请求呢? 换言之,我们可不可以实现一个线程池版的服务端呢?
当然是可以的, 我们以前不是实现过线程池 (具体我们选择单例模式的线程池) 吗? 因此,我们就将线程池引进来。
我们需要,Date.hpp、LockGuard.hpp、Log.hpp、Thread.hpp、Task.hpp、ThreadPool.hpp,前四个没有变化 (和以前进程池一样,如果想要再看看,请看下面链接),我们重点做出更改的是 Task.hpp, 而ThreadPool.hpp 也没有什么大改变,我们只是在原先的 run_all_thread函数中又调用了pthread_detach,避免了主线程等待新线程,因此 ThreadPool.hpp 也不再展示了。
多线程 --- [ 线程池、懒汉引发的线程安全问题、其他常见的锁 ]-CSDN博客
2.7.1. Task.hpp
#ifndef __TASK_HPP_
#define __TASK_HPP_
#include <iostream>
#include <functional>
#include <string>
#include "Log.hpp"
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
namespace Xq
{
//typedef std::function<void (int, const struct sockaddr_in&, const std::string& ) > func_t;
using func_t = std::function<void (int, const struct sockaddr_in&, const std::string& ) >;
class Task
{
public:
// 提供一个默认构造
Task() {}
Task(int server_sock, const struct sockaddr_in& client_addr, func_t func)
:_server_sock(server_sock)
,_client_addr(client_addr)
,_func(func)
{}
void operator()(const std::string& name)
{
// 这里的_func就是线程池中的线程处理客户端连接
_func(_server_sock, _client_addr, name);
// _func 调用完, 我们就可以关闭这个服务套接字
close(_server_sock);
LogMessage(DEBUG, "%s close server_sock: %d\n", name.c_str(),_server_sock);
}
private:
int _server_sock;
struct sockaddr_in _client_addr;
func_t _func;
};
}
#endif
2.7.2. version 4 版本的 Tcp.Server.hpp
#ifndef __TCP_SERVER_HPP_
#define __TCP_SERVER_HPP_
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <pthread.h>
#include "./thread_pool/Log.hpp"
#include "./thread_pool/ThreadPool.hpp"
#include "./thread_pool/Task.hpp"
#define SERVER_BUFFER 1024
namespace Xq
{
const static int backlog = 20;
class tcp_server
{
private:
public:
tcp_server(uint16_t port, const std::string& ip = "")
:_ip(ip), _port(port), _listen_sock(-1)
{
// 获得线程池唯一实例
_only_thread_pool = Xq::thread_pool<Xq::Task>::get_ptr_only_thread_pool(10);
}
void init_server() { /*省略*/ }
void start()
{
// 我们采用线程池的方案
// 让线程池去处理客户端连接
// 线程池初始化后, 启动线程
_only_thread_pool->run_all_thread();
// 子进程退出, 自动被操作系统回收, 避免僵尸问题
signal(SIGCHLD, SIG_IGN);
// 服务器永不退出
while(true)
{
// step 4: accept
// accept用于接收客服端发送的连接请求
// 那么客户端向服务端发送连接请求,
// 服务端需不需要知道客户端的地址信息呢?
// 显然需要知道, 因此accept的后两个参数
// 就是发起请求方的地址信息
struct sockaddr_in client; // 输出型参数
bzero(&client, sizeof client);
socklen_t client_len = sizeof client; // 输入输出型参数
// 另外, 如果服务端没有收到连接请求
// 那么服务端会在accept内部阻塞
// 如果有连接请求, 那么我就处理
// 连接成功, 返回一个套接字, 服务套接字
// 连接失败, 返回 -1;
int server_sock = accept(_listen_sock, reinterpret_cast<struct sockaddr*>(&client), &client_len);
if(server_sock == -1)
{
LogMessage(FATAL, "%s,%d, Line:%d\n", strerror(errno), __LINE__);
exit(4);
}
LogMessage(DEBUG, "accept success, return server_sock: %d\n", server_sock);
// 走到这里, 说明连接成功
// 就可以进行通信了
// 因为我们线程池已经就绪
// 那么此时我们就可以构造任务
Xq::Task task(server_sock, client, communicate_data);
// push到线程池中的任务标中
_only_thread_pool->push_task(task);
// 我们已经在 run_all_thread 内部调用了线程分离
// 因此不用担心内存泄漏问题
}
}
// 简单的echo服务器: 将客户端发送的数据返回给客户端
static void communicate_data(int server_sock, const struct sockaddr_in& client, const std::string &name)
{
// 提取客户端的ip, 网络字节序 -> 主机序列
uint16_t client_port = ntohs(client.sin_port);
// 提取客户端的port, 网络字节序 -> 主机序列
std::string client_ip = inet_ntoa(client.sin_addr);
char buffer[SERVER_BUFFER] = {0};
while(true)
{
buffer[0] = 0;
// read --- 读取客户端数据
ssize_t read_size = read(server_sock, buffer, SERVER_BUFFER - 1);
if(read_size > 0)
{
LogMessage(DEBUG, "%s read client data success, data size: %d\n", name.c_str(), read_size);
buffer[read_size] = 0;
printf("[%s][%d]: %s\n", client_ip.c_str(), client_port, buffer);
}
else if(read_size == 0)
{
// 代表客户端关闭连接了
LogMessage(NORMAL, "client switch off connect, me too\n");
break;
}
else
{
LogMessage(ERROR, "%d,%s\n", strerror(errno));
exit(5);
}
// write --- 向客户端写数据
ssize_t write_size = write(server_sock, buffer, strlen(buffer));
if(write_size > 0)
{
LogMessage(DEBUG, "write_size: %d\n", write_size);
}
}
}
~tcp_server() {}
private:
std::string _ip; // IP
uint16_t _port; // 端口
int _listen_sock; // 监听套接字
Xq::thread_pool<Xq::Task>* _only_thread_pool; // 线程池
};
}
#endif
在网络通信中,如果服务器在客户端连接建立后一直保持这个连接,并且服务端的线程一直在处理客户端的请求和响应,这种连接就被称为长连接。
多进程或者多线程一定要有执行流的上限。 为什么呢? 对于一个服务器来说, 如果来一个客户端请求服务端就创建一个进程或者是线程, 那么如果一瞬间来了大量的客户端请求呢?如果此时服务端仍旧一一创建执行流,那么很有可能会导致服务器挂掉,甚至操作系统都有可能受到影响。
而线程池就有一个非常明显的好处。 哪怕你业务量在大, 我就这么多执行流, 不会出现创建大量执行流而导致服务器出现问题。
换言之, 线程池的执行流是有限个的, 因此,我们尽量不要保持这种长连接, 因为如果线程池中的所有线程都在处理客户端连接,那么再来新的客户端请求,只能被阻塞了。
所以,在一般情况下,最好不要保持长连接,而是在客户端请求到达时,服务端处理完请求后立即关闭连接。这样可以释放资源,并让线程池中的线程可以用于处理其他请求,提高系统的并发能力。
有了上面的理解,如果我们想实现一个简单的英译汉词典,如何实现呢? 实际上,我们就只需要更改一下线程的回调即可, 具体就是更改上面的 communicate data 函数即可。
2.7.3. version 4.1 版本的 Tcp.Server.hpp
简单的英译汉词典, 并且客户端连接处理完后,就断开连接,客户端下次在通信时,在进行connect。
#ifndef __TCP_SERVER_HPP_
#define __TCP_SERVER_HPP_
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <pthread.h>
#include <unordered_map>
#include "./thread_pool/Log.hpp"
#include "./thread_pool/ThreadPool.hpp"
#include "./thread_pool/Task.hpp"
#define SERVER_BUFFER 1024
namespace Xq
{
const static int backlog = 20;
class tcp_server
{
public:
tcp_server(uint16_t port, const std::string& ip = "")
:_ip(ip), _port(port), _listen_sock(-1)
{
// 获得线程池唯一实例
_only_thread_pool = Xq::thread_pool<Xq::Task>::get_ptr_only_thread_pool(10);
}
void init_server()
{
// step 1: create socket
// AF_INET, 表示IPV4互联网协议
// SOCK_STREAM 表示这是面向字节流的
// 此时就表示创建一个基于IPV4地址族、面向字节流的套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_listen_sock == -1)
{
LogMessage(FATAL, "%s,%d, Line:%d\n", strerror(errno), __LINE__);
exit(1);
}
LogMessage(DEBUG, "socket success, return listen sock: %d\n", _listen_sock);
// step 2: bind socket
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(_port);
// 服务端采用任意地址
server.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
if(-1 == bind(_listen_sock, reinterpret_cast<const struct sockaddr*>(&server), sizeof server))
{
LogMessage(FATAL, "%s,%d, Line:%d\n", strerror(errno), __LINE__);
exit(2);
}
// 上面的过程和UDP几乎没有任何区别,
// 无非UDP是面向数据报 --- SOCK_DGRAM
// 而TCP是面向字节流的 --- SOCK_STREAM
// step 3: set listen socket
// TCP是面向连接的, 因此客户端和服务端首先要建立连接
// 而对于服务端来说, 它并不知道客户端会在什么时候发起连接请求
// 因此服务端是不是应该保持一种等待获取客户端连接请求的状态呢?
// 答案是的, 因此listen这个系统调用就是将特定套接字设置为监听状态,
// 获取客户端发起的连接请求。
// backlog: 这个数字在这里暂时不解释, 后面解释
// 一般情况下, 这个值不能太大, 也不能太小
if(listen(_listen_sock, backlog) == -1)
{
LogMessage(FATAL, "%s,%d, Line:%d\n", strerror(errno), __LINE__);
exit(3);
}
// server init done
LogMessage(DEBUG, "server init success\n");
}
void start()
{
// 我们采用线程池的方案
// 让线程池去处理客户端连接
// 线程池初始化后, 启动线程
_only_thread_pool->run_all_thread();
// 子进程退出, 自动被操作系统回收, 避免僵尸问题
signal(SIGCHLD, SIG_IGN);
// 服务器永不退出
while(true)
{
// step 4: accept
// accept用于接收客服端发送的连接请求
// 那么客户端向服务端发送连接请求,
// 服务端需不需要知道客户端的地址信息呢?
// 显然需要知道, 因此accept的后两个参数
// 就是发起请求方的地址信息
struct sockaddr_in client; // 输出型参数
bzero(&client, sizeof client);
socklen_t client_len = sizeof client; // 输入输出型参数
// 另外, 如果服务端没有收到连接请求
// 那么服务端会在accept内部阻塞
// 如果有连接请求, 那么我就处理
// 连接成功, 返回一个套接字, 服务套接字
// 连接失败, 返回 -1;
int server_sock = accept(_listen_sock, reinterpret_cast<struct sockaddr*>(&client), &client_len);
if(server_sock == -1)
{
LogMessage(FATAL, "%s,%d, Line:%d\n", strerror(errno), __LINE__);
exit(4);
}
LogMessage(DEBUG, "accept success, return server_sock: %d\n", server_sock);
// 走到这里, 说明连接成功
// 就可以进行通信了
// 因为我们线程池已经就绪
// 那么此时我们就可以构造任务
Xq::Task task(server_sock, client, english_to_chinese);
// push到线程池中的任务标中
_only_thread_pool->push_task(task);
// 我们已经在 run_all_thread 内部调用了线程分离
// 因此不用担心内存泄漏问题
}
}
static void english_to_chinese(int server_sock, const struct sockaddr_in& client, const std::string &name)
{
// 提取客户端的ip, 网络字节序 -> 主机序列
uint16_t client_port = ntohs(client.sin_port);
// 提取客户端的port, 网络字节序 -> 主机序列
std::string client_ip = inet_ntoa(client.sin_addr);
// 缓冲区,用于存放客户端传递过来的数据
char buffer[SERVER_BUFFER] = {0};
std::string ret_message_to_client = "";
ssize_t read_size = read(server_sock, buffer, SERVER_BUFFER - 1);
auto it = _dict.find(buffer);
if(read_size > 0)
{
LogMessage(DEBUG, "%s read client data success, data size: %d\n", name.c_str(), read_size);
buffer[read_size] = 0;
if(it == _dict.end())
{
ret_message_to_client = "我不知道";
}
printf("[%s][%d]: %s\n", client_ip.c_str(), client_port, buffer);
}
else if(read_size == 0)
{
// 代表客户端关闭连接了
LogMessage(NORMAL, "client switch off connect, me too\n");
return ;
}
else
{
// 读取错误
LogMessage(ERROR, "%d,%s\n", strerror(errno));
exit(5);
}
// write --- 向客户端写数据
if(_dict.end() != it)
{
ret_message_to_client = it->second;
}
ssize_t write_size = write(server_sock, ret_message_to_client.c_str(), ret_message_to_client.size());
if(write_size > 0)
{
LogMessage(DEBUG, "write_size: %d\n", write_size);
}
}
~tcp_server() {}
private:
std::string _ip; // IP
uint16_t _port; // 端口
int _listen_sock; // 监听套接字
Xq::thread_pool<Xq::Task>* _only_thread_pool; // 线程池
static std::unordered_map<std::string, std::string> _dict;
};
std::unordered_map<std::string, std::string> tcp_server::_dict = \
{{"victory","胜利"}, {"step", "步骤"}, {"process", "进程"}};
}
#endif
2.7.4. 与version 4.1版的服务端匹配的客户端
#include "./thread_pool/Log.hpp"
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
const int CLIENT_BUFFER = 1024;
void Usage(void)
{
printf("please usa ./tcp_client server_ip server_port\n");
exit(1);
}
int main(int arg, char** argv)
{
if(arg != 3)
{
Usage();
}
// 因为, 当客户端连接被处理完毕后
// 服务端的执行流就不会再处理这个客户端的连接了
// 因此如果还想继续向服务端发送数据
// 需要客户端重新连接
while(true)
{
// step 1: create socket
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock == -1)
{
LogMessage(FATAL, "%s,%d\n",strerror(errno));
exit(2);
}
// 获取服务端port, 并将其转为网络字节序
int server_port = htons(atoi(argv[2]));
// 获取服务端IP, 并将其转为网络字节序
uint32_t server_ip = inet_addr(argv[1]);
// 填充服务端地址信息
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_addr.s_addr = server_ip;
server.sin_port = server_port;
socklen_t server_len = sizeof(server);
if(connect(sock, reinterpret_cast<const struct sockaddr*>(&server), server_len) == -1 )
{
LogMessage(FATAL, "client connect failed\n");
exit(3);
}
LogMessage(DEBUG, "client connect success\n");
// 如果connect成功代表着该套接字(sock)
// 与这个地址信息强关联的进程(服务端进程)建立了连接
// 此时操作系统就会自动为客户端绑定
// 接下来, 客户端就可以和服务端交互数据了
std::cout << "请输入信息: " << std::endl;
std::string message;
getline(std::cin, message);
if(message == "quit")
{
LogMessage(DEBUG, "client quit\n");
break;
}
// 客户端向服务端发送数据 --- send
ssize_t real_send_size = send(sock, message.c_str(), message.size(), 0);
if(real_send_size > 0)
{
LogMessage(DEBUG, "send success\n");
}
else if(real_send_size == 0)
{
LogMessage(WARNING, "server disconnect\n");
break;
}
else
{
LogMessage(ERROR, "send failed\n");
break;
}
// 客户端收数据--- recv
char buffer[CLIENT_BUFFER] = {0};
ssize_t real_recv_size = recv(sock, buffer, CLIENT_BUFFER - 1, 0);
if(real_recv_size > 0)
{
buffer[real_recv_size] = 0;
printf("server: %s\n", buffer);
}
else
{
LogMessage(ERROR, "recv error\n");
break;
}
close(sock);
}
return 0;
}
3. 补充问题
1、 为什么客户端不需要显示绑定呢?
在客户端,通常不需要显式地绑定套接字。这是因为,如果客户端显式的bind了, 那么是不是就要求客户端一定bind了一个固定的IP和port, 那么如果其他客户端占用了这个port呢? 那不就会导致port失败吗?因此,客户端不需要显式的bind,而是操作系统自动bind,当客户端调用connect时,操作系统会自动选择IP (通常是本地机器的地址) 和 port 用以绑定客户端创建的套接字。
2、 为什么服务端使用 INADDR_ANY 这样的特殊地址来绑定套接字呢?
使用任意IP,可以使服务器能够接受来自任意IP地址的客户端连接,只要端口号一定, 凡是给我这台主机的数据,我都可以收到,因此,服务端一般只需要指明端口,IP采用任意地址方案。