C++ 网络编程:打造多线程 TCP 服务器,同时服务多个客户机!
C++ 网络编程:打造多线程 TCP 服务器,同时服务多个客户机!
- 一、阻塞与并发
- 二、std::thread 简介
- 三、实现过程
- 四、客户端连接
- 五、多线程服务器模型的优缺点
- 六、总结
一、阻塞与并发
几乎所有的程序员第一次接触到的网络编程都是从 listen()、send()、recv()
等接口开始的,这些接口都是阻塞型的。使用这些接口可以很方便的构建服务器/客户机的模型。
大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。
实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send()
的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。
一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。
主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。
二、std::thread 简介
std::thread
是 C++11 标准引入的一个类,用于在 C++ 中创建和管理线程。它提供了一种简单且高效的方式来实现多线程编程,允许程序同时执行多个操作。
std::thread
构造函数的主要功能是接收可调用对象(如函数、lambda 表达式、绑定的成员函数等),并在新线程中执行。
构造函数原型:
template <class F, class... Args>
explicit thread(F&& f, Args&&... args);
F
是可调用对象的类型,可以是普通函数、成员函数、lambda 表达式或任何实现了 operator()
的对象。Args
是可变参数模板,表示传递给可调用对象的参数。
线程管理:
join()
:在主线程中调用join()
可以等待线程完成,使主线程阻塞,直到新的线程执行完成。detach()
:若不希望主线程等待线程完成,可以调用detach()
,这将使线程与主线程分离,成为一个独立运行的线程。
每个 std::thread
对象都有一个状态,可以通过调用 joinable()
方法来检查一个线程是否可以被 join
。如果线程已经完成或被分离(detach
),则不能再进行 join
。
三、实现过程
基本步骤:
- 创建一个 socket。
- 设置端口并绑定。
- 设置网络IO的阻塞模式还是非阻塞模式,默认是阻塞模式。
- 监听端口。
- 接受客户端连接,开启多线程。
- 设置客户端socket fd 的网络 IO 是阻塞模式还是非阻塞模式,默认是阻塞模式。
- 收发数据,数据处理。
完整代码:
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include <thread>
#define PORT 8080
#define LINSTEN_BLOCK 20
#define BUFFER_LEN 4096
#define SET_NONBLOCK 0
bool setIoMode(int fd, int mode);
void routine(int clientfd);
int main(int argc, char**argv)
{
// 1. Create socket
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd == -1) {
std::cout << "socket return " << errno << ", " << strerror(errno) << std::endl;
return -1;
}
// 2. Set the port and bind it.
sockaddr_in serverAddr;
memset(&serverAddr, 0, sizeof(sockaddr_in));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = htons(INADDR_ANY); // bind ip address.
serverAddr.sin_port = htons(PORT); // bind port.
if (bind(listenfd, (sockaddr*)&serverAddr, sizeof(serverAddr)) == -1) {
std::cout << "bind return " << errno << ", " << strerror(errno) << std::endl;
return -2;
}
#if SET_NONBLOCK
// set nonblock mode.
setIoMode(listenfd, O_NONBLOCK);
#endif
// 3. listening port.
if (listen(listenfd, LINSTEN_BLOCK) == -1) {
std::cout << "listen return " << errno << ", " << strerror(errno) << std::endl;
return -3;
}
std::cout << "server listening port " << PORT << std::endl;
while(1) {
// 4. accept connect.
sockaddr_in clientAddr;
memset(&clientAddr, 0, sizeof(clientAddr));
socklen_t clienLen = sizeof(clientAddr);
int clientfd = accept(listenfd, (sockaddr *)&clientAddr, &clienLen);
if (clientfd == -1) {
std::cout << "accept return " << errno << ", " << strerror(errno) << std::endl;
continue;
}
std::cout << "client fd " << clientfd << std::endl;
std::thread thread(routine, clientfd);
thread.detach();
}
close(listenfd);
return 0;
}
bool setIoMode(int fd, int mode)
{
int flag = fcntl(fd, F_GETFL, 0);
if (flag == -1) {
std::cout << "fcntl get flags return " << errno << ", " << strerror(errno) << std::endl;
return false;
}
flag |= O_NONBLOCK;
if (fcntl(fd, F_SETFL, flag) == -1) {
std::cout << "fcntl set flags return " << errno << ", " << strerror(errno) << std::endl;
return false;
}
return true;
}
void routine(int clientfd)
{
const char *msg = "Hello, Client!";
while (1) {
// 5. send message.
if (send(clientfd, msg, strlen(msg), 0) == -1) {
std::cout << "send buffer return " << errno << ", " << strerror(errno) << std::endl;
continue;
}
// 6. recv message
char buffer[BUFFER_LEN];
int ret = recv(clientfd, buffer, BUFFER_LEN, 0);
if (ret == 0) {
std::cout << "client " << clientfd << " connection dropped" << std::endl;
break;
} else if (ret == -1) {
std::cout << "recv buffer return " << errno << ", " << strerror(errno) << std::endl;
break;
}
std::cout << "recv buffer from "<< clientfd << ": " << buffer << std::endl;
}
close(clientfd);
std::cout << "End of client " << clientfd << std::endl;
}
上述代码的核心是:用一个循环一直循环等待客户端接入(7 * 24 H),客户端接入后开启一个线程专门为该客户端服务,线程内部建立一个循环用于服务器和客户端的交互。
四、客户端连接
Windows下可以使用NetAssist的网络助手工具:
也可以使用如下的代码实现一个客户端:
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 8080
#define BUFFER_LEN 4096
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
if (-1 == connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr))) {
std::cout << "Connect failed!" << std::endl;
return -1;
}
for (int i = 0; i < 10; ++i) {
char buffer[BUFFER_LEN] = {0};
if (recv(sockfd, buffer, sizeof(buffer), 0) <= 0) {
break;
}
std::cout << "Message from server: " << buffer << std::endl;
const char *msg = "Hello, Client!";
if (send(sockfd, msg, strlen(msg), 0) == -1) {
std::cout << "send buffer return " << errno << ", " << strerror(errno) << std::endl;
break;
}
sleep(1);
}
close(sockfd);
return 0;
}
五、多线程服务器模型的优缺点
优点:
-
多线程服务器模型的设计理念是每个线程专门为一个客户端提供服务。这种一对一的模式使得程序逻辑简单,代码结构易于理解,易于维护和调试。
-
每个线程的执行和资源使用是相互独立的。一个线程的崩溃或阻塞不会直接影响到其他线程,进而提高了程序的稳定性。
-
自然地利用多核处理器。
-
在每个线程中,可以保持特定于客户的状态(即可在每个线程中维护客户端上下文),避免了对跨线程共享状态的复杂同步问题。
缺点:
-
资源消耗大:每个线程的创建和管理都会占用一定的系统资源(例如堆栈内存、线程控制块等)。对于高并发场景,这种资源消耗可能会显著增加,影响服务器的性能。
-
在高并发情况下,如果线程数目过多,上下文切换的开销会导致性能显著下降。
-
并发限制:每个系统对线程的数量是有限制的,通常一个服务器不能创建超过一万(具体限制视操作系统而定)个线程,这会对支持高并发服务构成瓶颈。
多线程服务器模型能够提供简单有效的解决方案,适合资源相对富余、对并发性能要求较低的应用。
六、总结
上述多线程的 服务器模型(一请求一线程) 似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。很多年之前(2010年前后)这个方法确实非常有效,但如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。
多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。