今天让我们来写一个tcp的服务器/客户端代码
完整代码见我的gitee 连接
阅读本文前,建议先阅读👉 udp服务器
1.基本框架 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; string _ip; 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> #include <sys/types.h> #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 ) { _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 struct sockaddr_in local;memset (&local,0 ,sizeof (local));local.sin_family = AF_INET; local.sin_port = htons (_port); 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 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> #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 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> #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); 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[]) { if (argc!=2 && argc!=3 ) { cout << "Usage: " << argv[0 ] << " port [ip]" << endl; return 1 ; } string ip; if (argc==3 ) { ip = argv[2 ]; } TcpServer t (atoi(argv[1 ]),ip) ; t.start (); return 0 ; }
先编译后执行代码,让tcp服务器运行起来
随后在浏览器的地址栏输入公网ip:端口
(先要开启防火墙内的端口)
此时会发现什么都加载不出来,这是对的,因为我们并没有写前端,也没有提供任何服务。但是来到后台,可以看到出现了一个新的连接,并显示出了ip+端口
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 ){ while (1 ) { } }
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 static void * threadRoutine (void *args) { pthread_detach (pthread_self ()); ClientData* data=(ClientData*)args; data->_this->transService (data->_fd,data->_ip,data->_port); delete data; return nullptr ; }
3.3.4 服务代码 解决了上面的问题,就可以继续往下看看服务端的代码了
1 2 3 4 5 pthread_t service;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; 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 ) { 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); cout << ret << endl; string retS = to_string (ret); cout << retS << endl; write (sockfd,retS.c_str (),retS.size ()); } else if (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); transService (conet, senderIP, senderPort); exit (0 ); } close (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 ) { 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 pid_t id = fork();if (id == 0 ){ close (_listenSock); if (fork()>0 ){ exit (0 ); } transService (conet, senderIP, senderPort); exit (0 ); } close (conet); pid_t ret = waitpid (id, nullptr , 0 ); assert (ret > 0 );
采用这种办法以后,由于父进程退出了,孙子进程会直接被操作系统接管 。下图中能看到这些进程的父进程都是1 ,即操作系统。这时候我们也不需要担心子进程的释放问题了
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" int main (int argc, char *argv[]) { if (argc != 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); } string serverIp = argv[1 ]; uint16_t serverPort = atoi (argv[2 ]); int sock = socket (AF_INET, SOCK_STREAM, 0 ); if (sock < 0 ) { cerr << "socket: " << strerror (errno) << endl; exit (SOCKET_ERR); } 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); 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 (); cout << "请输入你的消息# " ; getline (cin, message); 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); s = read (sock, (char *)(message.c_str ()), BUFFER_SIZE); if (s > 0 ) { message[s] = '\0' ; } cout << "Server Echo# " << message << endl; } else if (s <= 0 ) { break ; } } close (sock); return 0 ; }
4.1 运行测试 先运行服务端,再运行客户端,客户端输入后,服务短会返回字符串的ascii码总和
而客户端输入quit后,在服务端可以看到客户端退出,但服务端并没有推出,正在等待下一个连接
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 )>; 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 _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返回值
使用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服务器也能正常提供服务
但是!如果出现了一个尴尬的情况,线程池中的线程<当前需要连接的客户端数量,会发生什么呢?
5.4 task等待问题 为了测试,我们将线程池单例中的线程个数初始化为2个
此时,我们发现第三个客户端会进入阻塞状态,但实际上它已经成功链接上了服务器,task也被插入到了任务队列里面,只不过当前没有空闲的线程来运行它
如果我们把左侧其中一个客户端退出,最右侧的客户端就能正常收到服务器返回的结果了
要解决这个问题,我们就需要让线程池有能力判断是否出现了阻塞问题,并扩容线程来解决阻塞。
可是这又引出了另外一个问题:如果空闲了很久都没有任务过来,多出来的线程不就是在白白消耗资源吗?
实际上,线程池适合处理的,应该是短小的任务 ,而不是一个while(1)
循环;
但是我还没有学习到如何将其修改为服务于短小任务的线程池,仍待后续的精进