文章目录
- 网络编程套接字(二)
- 简单TCP服务器实现
- 创建套接字
- 服务器绑定
- 服务器监听
- 服务器接收连接
- 服务器处理请求
- 简单TCP客户端实现
- 创建套接字
- 客户端发起连接
- 客户端发起请求
- 服务器简单测试
- 服务器简单测评
- 多进程版TCP服务器
- 捕捉SIGCHLD信号
- 孙子进程提供服务
- 多线程版TCP服务器
- 线程池版TCP服务器
网络编程套接字(二)
简单TCP服务器实现
我们将会使用到的头文件放在comm.h
文件中
#include <iostream>
#include <memory.h>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
创建套接字
创建过程和UDP服务器几乎完全一样,除了使用的是TCP服务器使用的是流式服务(SOCK_STREAM),UDP使用的是数据包服务(SOCK_DGRAM)
#include "comm.h"
class TcpServer {
public:
TcpServer(){};
void InitServer(){
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
std::cerr << "socket error" << std::endl;
exit(2);
}
}
~TcpServer(){
if (sock >= 0) close(sock);
}
private:
void Socket();
int sock;
};
服务器绑定
绑定的过程和UDP服务器也是相同的,可以看着复习一下
// 2 服务器绑定
sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
std::cerr << "bind error" << std::endl;
exit(3);
}
std::cout << "server bind success" << std::endl;
定义好struct sockaddr_in
结构体后,最好使用memset
对该结构体进行初始化,也可以使用bzero
函数进行清空
void bzero(void *s, size_t n);
服务器监听
TCP服务器是面向连接的,,客户端在正式向TCP服务器发送数据之前必须先于TCP服务器建立连接,然后才可以进行通信
因此TCP服务器需要时刻注意是否有客户端发来连接请求,需要将TCP创建的套接字设置成监听状态
int listen(int socket, int backlog);
- socket : 需要设置为监听状态的套接字对应的文件描述符
- backlog: 全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接会先被放入连接队列,该参数代表这个连接队列的最大长度,一般设置成5或者10即可
- 监听成功返回0,失败返回-1同时设置错误码
// 3 服务器监听
if (listen(listen_sock, 10) < 0) {
std::cerr << "listen error" << std::endl;
exit(4);
}
这里我们发现上文的sockfd
其实是一个被监听的文件描述符,为了变量命名更容易让人理解,我们把sockfd
改为listen_sock
,并且在初始化TCP服务器中,只有套接字创建成功,绑定成功,监听成功,TCP服务器的初始化才算完成
vim 替换单词
全文替换 ::#sockfd#listen_sock#g
使用 :#str1#str2#g
进行全文替换,将str1全部替换成str2
局部替换: : 20, 30s#str1#str2#g
(将20到30行内的str1替换成str2)
当前行替换: : s#str1#str2#g
(将光标所在行内的str1 替换成 str2)
服务器接收连接
TCP服务器初始化后就可以开始运行了,但是TCP服务器与客户端在进行网络通信之前,服务器需要先获取到客户端的连接请求
int accept(int socket, struct sockaddr *restrict address,
socklen_t *restrict address_len);
-
socket : 特定的监听套接字,表示从该监听文件中获取连接
-
address : 对端网络相关的属性信息
-
addrlen
: 传入希望读取到的address结构体的长度,返回实际读取到的address结构体的长度,是一个输入输出参数 -
获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时设置错误码
监听套接字和accept函数返回套接字的区别
- 监听套接字:用于获取连接请求信息,accept函数不断从监听文件中获取新连接
- accept返回套接字:用于为这个连接提供服务,进行真正的业务数据传输
服务端获取连接
-
accept函数获取连接时可能会失败,但是服务器不能因为获取某一个连接失败就退出,因此服务端获取连接失败后还需要继续获取连接
-
如果需要将获取到的客户端IP地址和端口号信息进行输出,需要调用
inet_ntoa
函数将整数IP转换成字符串IP,调用ntohs
函数将网络序列转换成主机序列 -
inet_ntoa
函数会先将整数IP转换成主机序列,然后再将其转换成字符串IP
void Start() {
for (;;) {
// 获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0) {
std::cerr << "accept error, continue" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
short client_port = ntohs(peer.sin_port);
cout << "get a new link->" << sock << "[" << client_ip << ":" << client_port << "]" << endl;
Service(sock, client_ip, client_port); // 进行业务处理
}
}
服务器处理请求
现在服务器已经可以和客户端建立连接了,接下来就是到了通信阶段。我们只需要通过对accept
函数打开的网络文件进行读写,就可以完成网络数据的传输和接收了。为了能让双方都可以看到现象,这里就实现一个简单的回声TCP服务器
ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset);
ssize_t read(int fildes, void *buf, size_t nbyte);
-
fd
:特定的文件描述符,表示从该文件描述符中读取数据 -
buffer : 数据的存储位置,表示将读取数据到该缓冲区中
-
count : 期望从该文件描述符中读取的字节数
-
返回值大于零代表本次读取到的字节数,等于零表示对端对出,小于零说明读取出现错误
ssize_t pwrite(int fildes, const void *buf, size_t nbyte, off_t offset);
ssize_t write(int fildes, const void *buf, size_t nbyte);
-
fd
: 特定的文件描述符,表示将把数据写入该文件描述符对应的文件 -
buffer : 需要写入的数据
-
count :期望写入的字节数
-
写入成功返回实际写入的字节数,写入失败返回-1,同时错误码被设置
还需要注意到,服务端读取的数据是从服务套接字中获取的,如果客户端断开连接服务结束那么需要关闭对应文件。因为文件描述符本质就是数组的下标,是有限的,如果一直占用会导致文件描述符泄漏
void Service(int sock, std::string client_ip, short client_port) {
for (;;) {
#define BUFFER_SIZE 128
char buffer[BUFFER_SIZE];
ssize_t size = read(sock, buffer, BUFFER_SIZE - 1); // 读取请求
if (size > 0) { // 读取成功
buffer[size] = 0;
std::cout << client_ip << ":" << client_port << " # " << buffer << std::endl;
std::string response = "tcp server say # ";
response += buffer;
if (write(sock, response.c_str(), response.size()) < 0) { // 发送响应
std::cerr << "write response error" << std::endl;
} else {
std::cout << "send response success" << std::endl;
}
} else if (size == 0) { // 对端关闭连接
std::cout << client_ip << ":" << client_port << " quit..." << std::endl;
close(sock);
break;
} else { // 读取失败
std::cerr << "read request error" << std::endl;
std::cout << client_ip << ":" << client_port << " quit..." << std::endl;
close(sock);
break;
}
}
}
简单TCP客户端实现
创建套接字
class TcpClient{
public:
TcpClient(std::string& ip, short port)
: sockfd(-1), server_ip(ip), server_port(port){}
~TcpClient(){
if (sockfd > 0) close (sockfd);
};
int ClientInit() {
// 创建套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
std::cerr << "socket error" << std::endl;
exit(2);
}
std::cout << "socket success" << std::endl;
return 0;
}
private:
int sockfd;
std::string server_ip;
short server_port;
};
客户端发起连接
由于客户端不需要用户手动绑定也不需要监听,所以客户端创建好套接字后就可以直接向服务端发起连接请求
int connect(int socket, const struct sockaddr *address, socklen_t address_len);
- socket : 特定的套接字,表示通过该套接字发起连接请求
address
: 对端的网络相关信息addrlen
: 传入的addr
结构体的长度- 绑定成功返回0,连接失败返回-1,同时错误码被设置
void Start() {
// 发送连接请求
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
if (connect(sockfd, (struct sockaddr*)&server, sizeof(server)) < 0) {
std::cerr << "connect error" << std::endl;
exit(3);
}
std::cout << "connect success" << std::endl;
Request();
}
客户端发起请求
这里的代码逻辑非常简单,可以稍微看一看
void Request() {
for (; ;) {
std::string msg;
getline(cin, msg);
if (msg == "quit") {
break;
}
write(sockfd, msg.c_str(), msg.size());
#define BUFFER_SIZE 128
char buffer[BUFFER_SIZE];
ssize_t size = read(sockfd, buffer, sizeof(buffer) - 1);
if (size > 0) {
buffer[size] = 0;
std::cout << buffer << std::endl;
}
}
}
服务器简单测试
首先使用telnet
进行连接测试,可以看到服务器可以正常建立连接。控制telnet
给服务器发送信息服务器可以接收并能返回响应
使用netstat
命令查看,可以看到一个名为tcp_server
的进程正处于监听状态
使用我们的客户端连接,可以看到服务端可以打印客户端的IP地址和端口号以及发送的数据,客户端也可以接收服务器发来的响应。客户端一旦退出,服务器也会立刻接收到并作出反应。
服务器简单测评
当我们仅仅使用一个客户端连接服务端时,客户端可以正常享受服务器的服务。但是若再来一个客户端时,虽然新来的客户端也可以成功建立连接,但是我们的服务器正在为第一个客户端提供服务,无法立马处理第二个客户端的请求。只有等第一个客户端推出后,才能对第二个客户端发来的数据进行打印
单执行流服务器
这是因为我们的服务器是一个单执行流的,这种服务一一次只能为一个客户端提供服务。
单执行流服务器为什么可以同时和多个客户端建立连接
当服务端在给第一个客户端提供服务期间,第二个客户端发送连接请求时是成功的,这是因为连接其实已经建立,只是服务端还没有调用accept
函数将连接获取上来罢了
前文在介绍listen
接口的时候提到一个参数backlog
,实际上操作系统在底层会为我们维护一个连接队列。服务端没有accept的新连接会被放在这个连接队列中,而这个队列的最大长度是由backlog决定的。所以虽然服务端没有使用accept
获取第二个客户端发来的请求,但实际上链接已经建成了
那么如何解决服务器只能给一个客户端提供服务这个问题呢?? 很简单只要提供多进程版的服务器或者多线程版的就可以了
多进程版TCP服务器
当服务端调用accept函数获取到新连接,并未新连接打开网络文件后,让当前执行流调用fork()
函数创键子进程,让子进程为父进程获取到的链接提供服务,由于父子进程是两个不同的执行流,父进程创建子进程后可以继续从监听套接字中获取新连接,不需要关心服务
子进程会继承父进程的文件描述符表
文件描述符表是隶属于一个进程的,子进程创建后会"复制"一份父进程的文件描述符表,之后父子进程之间会保持独立性。对于套接字文件(网络文件)也是一样的,父进程创建的子进程也会继承父进程的套接字文件信息,此时子进程也就能对特定的套接字文件进行读写操作
等待子进程问题
当父进程创建子进程后,父进程是必须等待子进程退出的,以防止子进程变成僵尸进程造成内存泄漏。如果服务端进行阻塞式等待子进程,那么服务端还是必须等待客户端的服务完毕才能获取下一个服务请求,显然不合理。若采用非阻塞方式等待子进程,那么服务端就必须将所有子进程的PID保存下来,并每隔一段时间要对所有链接进行检测,显然非常麻烦
总之,无论采用阻塞或非阻塞的方式等待子进程,都不能很好的帮助我们将获取链接和提供服务分离。
不等待子进程退出的方式
1、 捕捉SIGCHLD信号,将其处理动作设置成忽略
2、让父进程创建子进程,子进程再创建孙子进程,子进程退出,孙子进程就被操作系统领养并为客户端进行服务
捕捉SIGCHLD信号
void Start() {
signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号
for (;;) {
// 获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0) {
std::cerr << "accept error, continue" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
short client_port = ntohs(peer.sin_port);
cout << "get a new link->" << sock << "[" << client_ip << ":" << client_port << "]" << endl;
pid_t id = fork(); // 创建子进程执行服务
if (id == 0) {
Service(sock, client_ip, client_port);
exit(0);
}
}
}
孙子进程提供服务
void Start() {
// signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号
for (;;) {
// 获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0) {
std::cerr << "accept error, continue" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
short client_port = ntohs(peer.sin_port);
cout << "get a new link->" << sock << "[" << client_ip << ":" << client_port << "]" << endl;
pid_t id = fork();
if (id == 0) { // 子进程
pid_t id = fork();
if(id == 0){ // 孙子进程
Service(sock, client_ip, client_port);
exit(0);
}
exit(0);
}
waitpid(id, NULL, 0); // 直接将子进程回收了
}
}
while :; do ps -axj | head -1 && ps -axj | grep tcp_server | grep -v grep; echo "############"; sleep 1; done # 监视脚本
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aZOZXrxO-1688734711200)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230707151802143.png)]
可以看到子进程直接就推出了,孙子进程正在为客户端提供服务。当客户端推出后,孙子进程直接就被操作系统回收了。它的PPID为1号进程,表明这是一个孤儿进程
多线程版TCP服务器
创建进程的成本非常高,而创建线程的成本就会小很多,因为线程本质就是再进程地址空间中运行的,创建出来的线程共享大部分资源。因此实现多执行流的服务器最好采用多线程进行实现
while :; do ps -aL | head -1 && ps -aL | grep tcp_server| grep -v grep; echo "#########################"; sleep 1; done
// 1、参数列表
struct Args{
Args(int _sock, std::string& _ip, short _port)
: sock(_sock), ip(_ip), port(_port) {}
int sock;
std::string ip;
short port;
};
void Start() {
for (;;) {
// 获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0) {
std::cerr << "accept error, continue" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
short client_port = ntohs(peer.sin_port);
cout << "get a new link->" << sock << "[" << client_ip << ":" << client_port << "]" << endl;
// 2、编写多线程部分
pthread_t tid;
struct Args *args = new struct Args(sock, client_ip, client_port);
pthread_create(&tid, NULL, Service, (void*)args);
pthread_detach(tid);
}
}
// 3、将Service函数改为静态函数,使用struct Args* 指针将三个参数构成结构体传进去
static void* Service(void* arg) {
struct Args* args = (struct Args*)arg;
int sock = args->sock;
std::string client_ip = args->ip;
short client_port = args->port;
delete args;
for (;;) {
#define BUFFER_SIZE 128
char buffer[BUFFER_SIZE];
ssize_t size = read(sock, buffer, BUFFER_SIZE - 1);
if (size > 0) { // 读取成功
buffer[size] = 0;
std::cout << client_ip << ":" << client_port << " # " << buffer << std::endl;
std::string response = "tcp server say # ";
response += buffer;
if (write(sock, response.c_str(), response.size()) < 0) {
std::cerr << "write response error" << std::endl;
} else {
std::cout << "send response success" << std::endl;
}
} else if (size == 0) { // 对端关闭连接
std::cout << client_ip << ":" << client_port << " quit..." << std::endl;
close(sock);
break;
} else { // 读取失败
std::cerr << "read request error" << std::endl;
std::cout << client_ip << ":" << client_port << " quit..." << std::endl;
close(sock);
break;
}
}
}
该对线程服务器存在的问题
- 每当新线程到来时,服务端的主线程才会为客户端创建新线程,而服务结束又会将该线程销毁。就像我们去食堂吃饭,我们去了食堂阿姨才开始做饭。效率低下。
- 如果有大量的客户端连接请求到来,计算机就要一一创建服务线程,线程越多CPU的压力也就越大。因为CPU要在这些线程之间来回切换,线程间切换的成本就变得很高,此外线程变多,每个线程被调度到的时间就变长了,用户体验变差
解决方案
- 可以预先创建一批线程,当有客户端请求连接到来时就为这些线程提供服务。而不是客户端来了才创建线程
- 当某个线程对客户端提供服务完成后,不让该线程推出,让该线程继续给下一个客户端提供服务。如果没有可以让线程先进入休眠状态
- 服务端创建的一批线程数量不能太多。此外,如果有海量客户端接连到来,可以将这些新来的连接放在等待队列中进行排队,等服务端这一批线程有空闲线程后再将连接拿上来处理
实际解决上述问题就是要让我们再服务端引入线程池。线程池可以预先存储线程并使线程循环往复的工作,并且线程池中还有一个任务队列可以用于存储任务。如果有任务就从任务队列中Pop任务,并调用任务对应的Run函数对任务进行处理,如果没有任务就进入休眠状态
线程池版TCP服务器
线程池的实现在多线程那一章已经讲过了,这里直接套用了
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>
#define THREAD_NUM 5
template<typename T>
class Thread_Pool{
public:
static Thread_Pool* GetInstance(size_t _thread_num = THREAD_NUM);
static void* Routine(void* arg);
~Thread_Pool();
void PushTask(const T& task);
void PopTask(T* task);
private:
Thread_Pool(size_t _thread_num);
private
bool IsEmpty() { return task_que.empty(); }
void QueueLock() { pthread_mutex_lock(&mtx); }
void QueueUnLock() { pthread_mutex_unlock(&mtx); }
void Wait() { pthread_cond_wait(&cond, &mtx); }
void Wakeup() { pthread_cond_signal(&cond); }
private:
size_t thread_num;
std::queue<T> task_que;
pthread_mutex_t mtx;
pthread_cond_t cond;
static Thread_Pool* instance;
};
template<typename T>
Thread_Pool<T>* Thread_Pool<T>::instance = nullptr;
template<typename T>
Thread_Pool<T>* Thread_Pool<T>::GetInstance(size_t _thread_num) {
if (instance == nullptr)
instance = new Thread_Pool(_thread_num);
return instance;
}
template<typename T>
Thread_Pool<T>::Thread_Pool(size_t _thread_num)
: thread_num(_thread_num){
pthread_mutex_init(&mtx, NULL);
pthread_cond_init(&cond, NULL);
for (int i = 0; i < thread_num; i++) {
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)this);
pthread_detach(tid);
}
}
template<typename T>
Thread_Pool<T>::~Thread_Pool() {
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
}
template<typename T>
void Thread_Pool<T>::PushTask(const T& task) {
QueueLock();
task_que.push(task);
QueueUnLock();
Wakeup();
}
template<typename T>
void* Thread_Pool<T>::Routine(void* arg) {
Thread_Pool<T>* tp = (Thread_Pool<T>*)arg;
tp->QueueLock();
while (tp->IsEmpty()) {
tp->Wait();
}
T* task = new T;
tp->PopTask(task);
tp->QueueUnLock();
task->Run();
// std::cout << task << std::endl; // for test
delete task;
}
template<typename T>
void Thread_Pool<T>::PopTask(T* task) {
*task = task_que.front();
task_que.pop();
}
现在想向服务器中引入线程池,因此在服务器类中新增一个线程池的指针成员
- 在实例化服务器对象时,先将线程池指针初始化为空
- 当服务器初始化完毕,进入正常运行阶段使用
GetInstance
接口获取单例线程池。 - 主线程之后就用于获取连接,然后将获取到的客户端
ip, port
以及打开的网络文件sockfd
打包成一个任务交给线程池的任务队列
线程池中的线程就通过不断获取任务队列中的任务,通过task
中包含的信息为客户端提供服务
这实际也是一个生产消费模型,其中监听进程就是任务的生产者,线程池中的若干线程就是消费者,交易场所就是线程池中的任务队列
任务类的设计
任务类中必须包含服务器和客户端进行通信所需要的数据信息,包含网络套接字,客户端的IP,客户端的端口号。表示该任务是为哪一个客户端提供服务的,使用的是哪一个网络文件
任务类中还必须带有一个Run()
方法,线程池中的线程拿到数据后交给Run
方法对任务进行处理(通信),这个方法实际就是上文实现的Service
函数,将其放入任务类中充当Run()
方法,但是这样实际上并不利于软件分层。我们可以给任务类新增一个仿函数,当任务执行Run方法处理任务时就可以以回调的方式处理该任务
#pragma once
#include <iostream>
#include "handler.hpp"
class Task{
public:
Task() {};
~Task() {};
Task(int _sockfd, std::string& _client_ip, short _client_port)
: sockfd(_sockfd), client_ip(_client_ip), client_port(_client_port){}
void Run(){ handler(sockfd, client_ip, client_port); }
private:
int sockfd;
std::string client_ip;
short client_port;
Handler handler;
};
仿函数类 Handler类
使用Handler类可以让我们的服务器处理不同的任务,实际想要怎么处理这个任务得由Handler函数定。如果想让服务器处理其它任务,只需要修改Handler当中的()重载函数即可,比如可以增加一个int 类型参数flag
,当flag ==1 , flag == 2 ……
的时候的就可以提供不同的处理方法
#include "comm.h"
class Handler{
public:
Handler(){};
~Handler(){};
void operator()(int sock, std::string client_ip, int client_port) {
for (;;) {
#define BUFFER_SIZE 128
char buffer[BUFFER_SIZE];
ssize_t size = read(sock, buffer, BUFFER_SIZE - 1);
if (size > 0) { // 读取成功
buffer[size] = 0;
std::cout << client_ip << ":" << client_port << " # " << buffer << std::endl;
std::string response = "tcp server say # ";
response += buffer;
if (write(sock, response.c_str(), response.size()) < 0) {
std::cerr << "write response error" << std::endl;
} else {
std::cout << "send response success" << std::endl;
}
} else if (size == 0) { // 对端关闭连接
std::cout << client_ip << ":" << client_port << " quit..." << std::endl;
close(sock);
break;
} else { // 读取失败
std::cerr << "read request error" << std::endl;
std::cout << client_ip << ":" << client_port << " quit..." << std::endl;
close(sock);
break;
}
}
}
};
现在无论有多少个客户端发送请求,服务端只会有5个线程为其提供服务,线程池中的线程数不会因为客户端的增多而增多。这些线程也不会因为客户端的退出而退出
参考文章:「2021dragon」的文章
原文链接:https://blog.csdn.net/chenlong_cxy/article/details/124650187