今天让我们来写一个tcp的服务器/客户端代码

完整代码见我的gitee 连接

阅读本文前,建议先阅读👉 udp服务器

1.基本框架

tcp的服务器和udp服务器初始化接口是非常相似的,区别就在于要选择字节流进行初始化

但是到运行状态就不同了

  • tcp是需要连接的
  • udp不需要连接

所以就会出现分歧:udp可以用sendto和receve来发送/接收信息,服务端只需要监听特定端口收到了什么信息;

但tcp并不能这么做,在通信之前,tcp服务器必须要和客户端建立链接。

举个不恰当的例子,udp服务器好比一个水盆,等待水的注入;而tcp服务器是个水管,必须要两头连通了,才能开始注水

1.1 类成员

类的成员变量和udp很相似,都是需要服务器的ip、端口、sockfd这些信息。为了更容易区分,将tcp服务器的socket fd改为_listenSock,意为监听端口

1
2
3
4
5
6
7
8
9
10
11
class TcpServer{


private:
// 服务器端口号
uint16_t _port;
// 服务器ip地址
string _ip;
// 服务器socket fd信息
int _listenSock;
};

1.2 头文件

这里对头文件进行一定的说明,因为服务器代码中的头文件实在太多了

当你需要使用一个接口的时候,可以去采用man手册来获取该接口的头文件信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#pragma once
// 头文件太多了,所以新起一个文件
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <cassert>
#include <ctype.h>//判断字符串大写小写接口需要的库
#include <unistd.h>
#include <strings.h>// 忽略大小写比较strcasecmp
#include <sys/types.h> //很多liunx系统接口都需要这个
#include <sys/socket.h> // 网络
#include <netinet/in.h> // 网络
#include <arpa/inet.h> // 网络
using namespace std;

#define SOCKET_ERR 1
#define BIND_ERR 2
#define LISTEN_ERR 3
#define USAGE_ERR 4
#define CONN_ERR 5

#define BUFFER_SIZE 1024

2.初始化

接口的介绍就跟随实现一步一步来吧

2.1 构造sock

这里出现了tcp和udp第一个不同之处,tcp是面向字节流的,udp面向的是数据报

1
2
3
4
5
6
7
8
9
10
11
12
TcpServer(uint16_t port,const string& ip="")
:_port(port), _ip(ip), _listenSock(-1)
{
// 1.创建socket套接字,采用字节流(即tcp)
_listenSock = socket(AF_INET, SOCK_STREAM, 0); //本质是打开了一个文件
if (_listenSock < 0)
{
logging(FATAL, "socket:%s:%d", strerror(errno), _listenSock);
exit(1);
}
logging(DEBUG, "socket create success: %d", _listenSock);
}

2.2 初始化sockaddr_in

继续,初始化sockaddr_in的操作和udp是完全一致的

1
2
3
4
5
6
7
8
9
10
11
// 2. 绑定网络信息,指明ip+port
// 2.1 先填充基本信息到 struct sockaddr_in
struct sockaddr_in local;
memset(&local,0,sizeof(local));//初始化
// 协议家族,设置为ipv4
local.sin_family = AF_INET;
// 端口,需要进行 本地->网络转换
local.sin_port = htons(_port);
// 配置ip
// 如果初始化时候的ip为空,则调用INADDR_ANY代表任意ip。否则对传入的ip进行转换后赋值
local.sin_addr.s_addr = _ip.empty() ? htonl(INADDR_ANY) : inet_addr(_ip.c_str());

2.3 bind

也是一样,绑定服务器的ip和端口

1
2
3
4
5
6
7
// 2.2 绑定ip端口
if (bind(_listenSock,(const struct sockaddr *)&local, sizeof(local)) == -1)
{
logging(FATAL, "bind: %s:%d", strerror(errno), _listenSock);
exit(2);
}
logging(DEBUG,"socket bind success: %d", _listenSock);

2.4 监听listen

对于tcp服务器来说,成员变量的_listenSock是用来监听的,即找个老哥一直盯着云服务器的这个端口,看看有没有需要连接它的客户端

1
2
3
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);

其中第一个参数是我们的_listenSock,第二个参数是用于限制在阻塞等待连接的数量

1
The backlog argument defines the maximum length to which the queue of pending connections for sockfd may grow.   If  a connection  request arrives when the queue is full, the client may receive an error with an indication of ECONNREFUSED or, if the underlying protocol supports retransmission, the request may be ignored so that a later reattempt  at  con‐nection succeeds.

翻译过来就是,backlog参数限制了能被阻塞等待连接的数量。如果超过这个数量,则会返回一个ECONNREFUSED错误。亦或者如果协议支持重传,多余的请求会被忽略,后续可以重传

man手册下面的notes还有更多解释

1
2
3
The behavior of the backlog argument on TCP sockets changed with Linux 2.2.  Now it specifies  the  queue  length  for completely  established  sockets waiting to be accepted, instead of the number of incomplete connection requests.  The maximum length of the queue for incomplete sockets can be set using /proc/sys/net/ipv4/tcp_max_syn_backlog.  When syn‐cookies are enabled there is no logical maximum length and this setting is ignored.  See tcp(7) for more information.

If the backlog argument is greater than the value in /proc/sys/net/core/somaxconn, then it is silently truncated to that value; the default value in this file is 128. In kernels before 2.4.25, this limit was a hard coded value,SOMAXCONN, with the value 128.

如果backlog参数高于/proc/sys/net/core/somaxconn中的默认值128,则会被截断为128


在我们这里,将其设置为5即可,反正也是做测试嘛,问题不大

1
2
3
4
5
6
7
8
// 3.监听
// tcp服务器是需要连接的,连接之前要先监听有没有人来连
if (listen(_listenSock, 5) < 0)
{
logging(FATAL, "listen: %s", strerror(errno));
exit(LISTEN_ERR);
}
logging(DEBUG, "listen: %s, %d", strerror(errno), _listenSock);

3.运行

初始化到这就完毕了,下面就是开跑了

3.1 accept

1
2
3
4
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

这个接口的作用就相当于tcp中的recevefrom,传参是完全相同的;与之不同的是,该函数的返回值是一个全新的sockfd

  • tcp需要和客户端建立链接
  • 链接需要用socket fd 来管理
  • 所以accept必须返回新的socket fd,让服务端有办法管理新的链接和已有链接
  • 原有的socket fd不受影响
  • 如果没有客户端来连接,进程会在accept内阻塞等待

下为man手册中的描述

1
The  accept()  system  call  is  used  with connection-based socket types (SOCK_STREAM, SOCK_SEQPACKET).  It extracts the first connection request on the queue of pending connections for the listening socket, sockfd, creates a new connected  socket,  and  returns  a  new  file descriptor referring to that socket.  The newly created socket is not in the listening state.  The original socket sockfd is unaffected by this call.

举个例子,tcp服务器自身的socket fd只会用来监听端口上有没有消息,当监听到有消息并通过accept建立连接后,就会让另外一位服务员来对这个连接提供服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
while(1)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 获取连接
int conet = accept(_listenSock,(struct sockaddr*)&peer,&len);
if(conet<0)
{
logging(FATAL, "accept: %s", strerror(errno));
exit(CONN_ERR);//连接错误
}
//。。。
}

注意这里len的参数是socklen_t,其本质上是一个无符号整形

1
2
3
typedef __socklen_t socklen_t;
__STD_TYPE __U32_TYPE __socklen_t;
#define __U32_TYPE unsigned int

3.2 获取连接信息

这部分和udp是完全相同的,通过accept返回的socket fd,获取用户的ip和端口耨

1
2
3
4
// 获取连接信息
string senderIP = inet_ntoa(peer.sin_addr);// 来源ip
uint16_t senderPort = ntohs(peer.sin_port); // 来源端口
logging(DEBUG, "accept: %s | %s[%d], socket fd: %d", strerror(errno), senderIP.c_str(), senderPort, conet);

其实到这里,我们就可以运行服务器进行测试了。因为tcp的特性,我们不需要写客户端,直接用浏览器就能连上服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main(int argc,char* argv[])
{
//参数只有两个(端口/ip)所以参数个数应该是2-3
if(argc!=2 && argc!=3)
{
cout << "Usage: " << argv[0] << " port [ip]" << endl;
return 1;
}

string ip;
// 3个参数,有ip
if(argc==3)
{
ip = argv[2];
}
TcpServer t(atoi(argv[1]),ip);
t.start();

return 0;
}

先编译后执行代码,让tcp服务器运行起来

image-20230207081805916

随后在浏览器的地址栏输入公网ip:端口(先要开启防火墙内的端口)

image-20230207081754768

此时会发现什么都加载不出来,这是对的,因为我们并没有写前端,也没有提供任何服务。但是来到后台,可以看到出现了一个新的连接,并显示出了ip+端口

image-20230207082138291

3.3 提供服务(线程)

接下来要做的,就是写一个简单的服务了,这里我写的是字符串转ASCII码,会将发出去的字符串的ascii码加加起来后返回

3.3.1 问题1 如何通信

此时问题就来了,tcp服务器不能使用recevefrom和sendto,那么获取到socket之后要怎么进行通信呢?

答案是:用linux的文件读写接口,read和write。别忘了,socket fd本质上就是一个linux下的文件描述符!

3.3.2 问题2 多客户端

tcp服务器要想给多个客户端提供服务,就必须采用多线程/多进程的方式来实现操作。否则会出现一个严重的问题,服务端因为提供服务而没有accept,无法链接上下一个客户端

1
2
3
4
5
6
7
8
9
10
while(1)
{
//accept 获取到链接上的客户端

while(1)
{
//如果在这里提供服务,则会其他连接会在listen里面阻塞
//只有当前服务终止了,其他客户端的其中之一才能连上服务器
}
}

3.3.3 问题3 线程传参

既然需要采用多线程服务,那就需要设定好给线程传的参数。理论上来说,我们只需要传入accept的返回值socket fd即可进行read/write

但实际上,我们还需要打印debug消息,要知道当前是谁向你发送了这条消息,ip和端口是什么。

为了方便操作,这里封装一个结构体,将socket fd,客户端的ip+端口封装成一个参数进行传参(线程的函数只能传入一个参数

1
2
3
4
5
6
7
8
9
10
11
struct ClientData
{
int _fd;
uint16_t _port;
string _ip;
TcpServer* _this;

ClientData(int fd,uint16_t port,const string& ip,TcpServer* this1)
:_fd(fd),_port(port), _ip(ip),_this(this1)
{}
};

你可能会想到另外一个办法,那就是在tcp服务器的class中新增一个map成员变量,用于映射socket fd和客户端信息的键值对,但是这无法实现。

因为在类中设计的多线程函数,为了去掉默认传入的this指针,必须要设置成static静态的,此时该静态函数无法访问类内成员!

1
2
3
4
5
6
7
8
9
10
// 因为需要取消this指针,所以需要设置成静态的
static void* threadRoutine(void*args)
{
pthread_detach(pthread_self()); //设置线程分离
ClientData* data=(ClientData*)args;
// 通过预先设置的this指针来访问类内成员,并进行传参
data->_this->transService(data->_fd,data->_ip,data->_port);
delete data;
return nullptr;
}

3.3.4 服务代码

解决了上面的问题,就可以继续往下看看服务端的代码了

1
2
3
4
5
// 提供服务
pthread_t service;
// 因为这个成员使用范围极小,所以采用new/delete,避免占用太多空间
ClientData* data = new ClientData(conet,senderPort,senderIP,this);
pthread_create(&service,nullptr,threadRoutine,(void*)data);

在accept之后,通过线程操作用线程来提供服务

1
2
3
4
5
6
7
8
9
static void* threadRoutine(void*args)
{
pthread_detach(pthread_self()); //设置线程分离
ClientData* data=(ClientData*)args;
// 通过预先设置的this指针来访问类内成员,并进行传参
data->_this->transService(data->_fd,data->_ip,data->_port);
delete data;
return nullptr;
}

threadRoutine的作用就是把线程的单参数转为多参数,传给真正用来服务的函数。函数的操作很简单,就是Linux下文件操作的读写。

读写成功后,将客户端发来的信息转成ASCII码的和发回给客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
void transService(int sockfd, const string &clientIp, uint16_t clientPort)
{
assert(sockfd >= 0);
assert(!clientIp.empty());
assert(clientPort>0);
// 开始服务
char buf[BUFFER_SIZE];
while(1)
{
// 读取客户端发来的信息,s是读取到的字节数
ssize_t s = read(sockfd, buf, sizeof(buf)-1);
if(s>0)
{
buf[s]='\0';//手动添加字符串终止符
if(strcasecmp(buf,"quit")==0)
{//客户端主动退出
break;
}
// 服务
string tmp = buf;
int ret = str2ascii(tmp);//获取字符串的ascii总和
cout << ret << endl;
string retS = to_string(ret);//转字符串
cout << retS << endl;
write(sockfd,retS.c_str(),retS.size());//写入
}
else if (s == 0)
{//s == 0代表对方关闭,客户端退出
logging(DEBUG, "client quit: %s[%d]", clientIp.c_str(), clientPort);
break;
}
else
{
logging(DEBUG, "read err: %s[%d] - %s", clientIp.c_str(), clientPort, strerror(errno));
continue;
}
}
close(sockfd);
logging(DEBUG,"server quit %d",sockfd);
}

private:
// 服务函数可以不暴露
int str2ascii(const string& str)
{
int ret = 0;
for(auto e:str)
{
ret += e;
}
return ret;
}

3.4 提供服务(子进程)

上面的代码采用的是线程来提供服务,除了线程,我们还有父子进程的方式,也能避免阻塞

需要注意的是,父子进程都需要关闭掉对方使用的文件描述符,避免出现文件描述符在服务结束后还没有关闭的情况

1
2
3
4
5
6
7
8
9
10
11
12
pid_t id = fork();
if(id == 0)
{
close(_listenSock);//因为子进程不需要监听,所以关闭掉监听socket
//子进程
transService(conet, senderIP, senderPort);
exit(0);// 服务结束后,退出,子进程会进入僵尸状态等待父进程回收
}
// 父进程
close(conet); // 因为此时是子进程提供服务,conet会有拷贝,相当于有两个进程打开了该文件
// 如果父进程不关闭,即便子进程结束服务了,该文件描述符也会保持开启
pid_t ret = waitpid(id, nullptr, 0);

直接这样写有一个很大的问题,那就是父进程没有办法正常释放子进程的资源

  • 如果进行阻塞等待,那就违背了初衷,完全没有意义
  • 如果进行非阻塞等待,在waitpid结束之后,父进程直接去干其他事了,完全忘记了这里的这个子进程

所以我们要做的,就是在子进程退出,向父进程发送信号的时候回收子进程

3.4.1 信号回收子进程

通过自定义捕捉系统信号,来回收子进程

1
signal(SIGCHLD, FreeChild);//自定义捕捉
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 回收子进程
void FreeChild(int signo)
{
assert(signo == SIGCHLD);
while (true)
{
//如果没有子进程了,waitpid就会调用失败
pid_t id = waitpid(-1, nullptr, WNOHANG); // 非阻塞等待
if (id > 0)
{
cout << "父进程等待成功, child pid: " << id << endl;
}
else if(id == 0)
{
//还有子进程没有退出
cout << "尚有未退出的子进程,父进程继续运行" << endl;
break;//退出等待子进程
}
else
{
cout << "父进程等待所有子进程结束" << endl;
break;
}
}
}

除了自定义捕捉,我们还可以设置成ignore不搭理子进程,这样子进程退出的时候就会被系统自动释放

1
signal(SIGCHLD, SIG_IGN);

3.4.2 爷爷进程

这里还有另外一个骚操作,那就是在创建子进程之后,再创建一个子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 提供服务(孙子进程)-2
pid_t id = fork();
if(id == 0)
{
close(_listenSock);//因为子进程不需要监听,所以关闭掉监听socket
//又创建一个子进程,大于0代表是父进程,即创建完子进程后父进程直接退出
if(fork()>0){
exit(0);
}

// 孙子进程执行
transService(conet, senderIP, senderPort);
exit(0);// 服务结束后,退出,子进程会进入僵尸状态等待父进程回收
}
// 爷爷进程
close(conet);
pid_t ret = waitpid(id, nullptr, 0); //此时就可以直接用阻塞式等待了
assert(ret > 0);//ret如果不大于0,则代表等待发生了错误

采用这种办法以后,由于父进程退出了,孙子进程会直接被操作系统接管。下图中能看到这些进程的父进程都是1,即操作系统。这时候我们也不需要担心子进程的释放问题了

image-20230207111205871

4.客户端

客户端部分的代码和udp也很相似,只不过将sendto改成了write

下方提供了客户端的代码,都写了注释😁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include "utils.h"


// ./clientTcp serverIp serverPort
int main(int argc, char *argv[])
{
if (argc != 3)//客户端必须要有3个参数
{
cerr << "Usage:\n\t" << argv[0] << " serverIp serverPort" << endl;
cerr << "Example:\n\t" << argv[0] << " 127.0.0.1 8080\n"
<< endl;
exit(USAGE_ERR);
}
// 解析服务端的ip和端口
string serverIp = argv[1];
uint16_t serverPort = atoi(argv[2]);

// 1. 创建tcp的socket SOCK_STREAM
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
cerr << "socket: " << strerror(errno) << endl;
exit(SOCKET_ERR);
}

// 2. connect,发起链接请求,你想谁发起请求呢??当然是向服务器发起请求喽
// 2.1 先填充需要连接的远端主机的基本信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverPort);
inet_aton(serverIp.c_str(), &server.sin_addr);
// 2.2 发起请求,connect 会自动bind
if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0)
{
cerr << "connect: " << strerror(errno) << endl;
exit(CONN_ERR);
}
cout << "connect success: " << sock << endl;

// 客户端发现的消息
string message;
while (1)
{
message.clear();//每次循环开始,都清空一下msg
cout << "请输入你的消息# ";
getline(cin, message);//获取输入
// 如果客户端输入了quit,则退出
if (strcasecmp(message.c_str(), "quit") == 0)
break;
// 向服务端发送消息
ssize_t s = write(sock, message.c_str(), message.size());
if (s > 0) // 写入成功
{
message.clear();//清空输入的消息
message.resize(BUFFER_SIZE);
// 因为string的c_str本质上是返回地址,所以强转后是可以往里面写入的
s = read(sock, (char *)(message.c_str()), BUFFER_SIZE);// 获取服务端的结果
if (s > 0)// 读取成功
{
message[s] = '\0';//追加\0
}
// 打印返回值
cout << "Server Echo# " << message << endl;
}
else if (s <= 0) // 写入失败
{
break;
}
}
// 关闭文件描述符
close(sock);
return 0;
}

4.1 运行测试

先运行服务端,再运行客户端,客户端输入后,服务短会返回字符串的ascii码总和

image-20230207095713615

而客户端输入quit后,在服务端可以看到客户端退出,但服务端并没有推出,正在等待下一个连接

image-20230207095848863

5.线程池

在上面的操作中,每次提供服务都需要当场新建一个线程。对于tcp这种要求高性能的网络服务器而言,其实是不太合适的。理论上来说,我们希望越早给客户建立联系越好,而不是食客都来了老板才去买菜。

这时候,就可以把我们写的线程池和tcp服务器给联系起来!

线程池的代码见我的gitee,此处只说明task类的编写

5.1 task

先前编写线程池的时候,将线程池要处理的任务写成了一个task类,并规定所有task类都需要提供一个()操作符重载,即仿函数。这样线程池就可以一视同仁的处理这些工作,我们只需要将新增的工作添加到线程池的任务队列里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Task
{
using callback_t = std::function<void (int, std::string, uint16_t)>;//相当于typedef
public:
Task() = default;
// 将需要调用的函数传入,相当于通用
Task(int sockfd, const std::string &clientIP, uint16_t clientPort,callback_t func)
: _sockfd(sockfd), _ip(clientIP), _port(clientPort),_func(func)
{
}
// 仿函数
void operator()()
{
logging(DEBUG, "TID[%p] = %s:%d START",\
pthread_self(), _ip.c_str(), _port);

_func(_sockfd, _ip, _port);

logging(DEBUG, "TID[%p] = %s:%d END ",\
pthread_self(), _ip.c_str(), _port);
}

private:
int _sockfd;
std::string _ip;
uint16_t _port;
callback_t _func;
};

这里,我又将task队列给封装成了一个可以接收函数指针的方式。这样一来,只要我们任务的函数参数为(SOCKET,IP,PROT),就能传入给这个task类,让线程池来运行

5.2 tcpServer的处理

因为需要线程池,我们在tcpserver中添加一个线程池的指针,通过线程池的类名来获取单例,赋值给成员变量。并让获取到的单例线程池开始运行

1
2
3
// 4.获取线程池 单例
_tpool = ThreadPool<Task>::getInstance(4);
_tpool->start();//开始运行

start函数中,则将之前的任务函数实例化为一个task,并将其push到线程池的任务队列中

1
2
3
// 提供服务(通过线程池)
Task t(conet,senderIP,senderPort,transService);
_tpool->push(t);

这样,就能通过线程池来提供服务了!

5.3 运行测试

可以看到,我们的线程池正确运行了任务,给客户端提供了ascii返回值

image-20230209094304860

使用ps -aL命令查看轻量级进程,可以看到有4个线程在为我们服务

1
2
3
4
5
6
7
8
 PID   LWP TTY          TIME CMD
7288 7288 pts/8 00:00:00 tcpServer
7288 7289 pts/8 00:00:00 handler
7288 7290 pts/8 00:00:00 handler
7288 7291 pts/8 00:00:00 handler
7288 7292 pts/8 00:00:00 handler
7441 7441 pts/9 00:00:00 tcpClient
7632 7632 pts/7 00:00:00 ps

此时,即便我们多开几个终端,tcp服务器也能正常提供服务

image-20230209094632613

但是!如果出现了一个尴尬的情况,线程池中的线程<当前需要连接的客户端数量,会发生什么呢?

5.4 task等待问题

为了测试,我们将线程池单例中的线程个数初始化为2个

image-20230209094741651

此时,我们发现第三个客户端会进入阻塞状态,但实际上它已经成功链接上了服务器,task也被插入到了任务队列里面,只不过当前没有空闲的线程来运行它

image-20230209095249694

如果我们把左侧其中一个客户端退出,最右侧的客户端就能正常收到服务器返回的结果了

image-20230209095429982

要解决这个问题,我们就需要让线程池有能力判断是否出现了阻塞问题,并扩容线程来解决阻塞。

可是这又引出了另外一个问题:如果空闲了很久都没有任务过来,多出来的线程不就是在白白消耗资源吗?

实际上,线程池适合处理的,应该是短小的任务,而不是一个while(1)循环;

但是我还没有学习到如何将其修改为服务于短小任务的线程池,仍待后续的精进