diff --git a/Experiment_3/README.md b/Experiment_3/README.md new file mode 100644 index 0000000..47bca26 --- /dev/null +++ b/Experiment_3/README.md @@ -0,0 +1,289 @@ +## 多线程客户端 + +我们前面几次实验所使用的客户端都是单线程的,只具有发送功能,但一个具备聊天功能的客户端应该同时具备发送和接收信息的功能,因此我们需要使用多线程来实现客户端,一个线程可以接收消息并打印,另一个线程可以输入信息并发送。 + +#### 知识点 + +- 多线程客户端设计 +- TCP 套接字网络编程 +- C++ 11 的 thread 库使用 +- 面向对象程序设计思想 + +#### 具体要求 + +把前面实验的客户端升级为多线程客户端(一个线程用于接收并打印信息、一个线程用于输入并发送信息),为前面实验的多线程服务器添加自动回复客户端的代码,用一个终端运行服务器程序,多个终端运行客户端程序,多个客户端都能发送信息送达服务器并收到服务器的应答,并将应答打印到客户端终端上,当用户在客户端输入 exit 时,要结束两个线程之后再结束客户端进程。 + +1. 编写一个客户端类 client ,有发送线程和接收线程,可以同时发送消息和接收消息。 +2. 要编写多个源代码文件:client 头文件给出 client 类声明、`client.cpp` 给出类方法具体实现、`test_client.cpp` 中编写主函数创建 client 实例对象并测试。 +3. 当用户在客户端输入 exit 时,要结束发送线程和接收线程之后才退出主线程。 +4. 服务器程序要在实验 3 的基础上进行一定修改,能够回复消息。 +5. 编写 Makefile 进行自动编译,使用 git 管理版本。 + +#### 设计思路 + +客户端应先 connect 服务器建立连接,成功连接之后就创建发送线程和接收线程,与服务器类的设计同理,我们需要将发送线程和接收线程的函数设为静态成员函数,发送线程和接收线程中都使用 while(1) 的循环结构,循环终止的条件是用户输入了 exit 或者对端关闭了连接。 + +#### 在 global.h 中写头文件 + +考虑到 client 类和 server 类会用到许多相同的头文件,因此我们没必要每次都重新写各种头文件,我们可以编写一个 `global.h`,在里面写上所有我们需要的头文件(甚至全局变量),让 `server.h` 和 `client.h` 都引入这个 `global.h` 即可,这样通过 `global.h` 就可以包含所有头文件,没那么容易乱。 + +`global.h` 文件内容如下所示: + +```cpp +#ifndef _GLOBAL_H +#define _GLOBAL_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace std; + +#endif +``` + +#### 具体实现 + +首先在 `client.h` 头文件中给出 client 类的成员变量和成员函数声明,该类有三个成员变量,同时有构造函数、析构函数、run 函数、发送线程函数、接收线程函数: + +```cpp +#ifndef CLIENT_H +#define CLIENT_H + +#include "global.h" + +class client{ + private: + int server_port;//服务器端口 + string server_ip;//服务器ip + int sock;//与服务器建立连接的套接字描述符 + public: + client(int port,string ip); + ~client(); + void run();//启动客户端服务 + static void SendMsg(int conn);//发送线程 + static void RecvMsg(int conn);//接收线程 +}; +#endif +``` + +接下来在 `client.cpp` 给出具体的函数定义。构造函数负责初始化服务器 ip 和端口号,析构函数负责关闭套接字描述符: + +```cpp +#include "client.h" + +client::client(int port,string ip):server_port(port),server_ip(ip){} +client::~client(){ + close(sock); +} +``` + +run 函数负责建立与服务器的连接并且启动发送线程和接收线程: + +```cpp +void client::run(){ + + //定义sockfd + sock = socket(AF_INET,SOCK_STREAM, 0); + + //定义sockaddr_in + struct sockaddr_in servaddr; + memset(&servaddr, 0, sizeof(servaddr)); + servaddr.sin_family = AF_INET; + servaddr.sin_port = htons(server_port); //服务器端口 + servaddr.sin_addr.s_addr = inet_addr(server_ip.c_str()); //服务器ip + + //连接服务器,成功返回0,错误返回-1 + if (connect(sock, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) + { + perror("connect"); + exit(1); + } + cout<<"连接服务器成功\n"; + + //创建发送线程和接收线程 + thread send_t(SendMsg,sock),recv_t(RecvMsg,sock); + send_t.join(); + cout<<"发送线程已结束\n"; + recv_t.join(); + cout<<"接收线程已结束\n"; + return; +} +``` + +发送线程负责接收用户的输入并且 send 到服务器端,如果用户输入 exit 或者出现异常时将结束线程: + +```cpp +//注意,前面不用加static! +void client::SendMsg(int conn){ + char sendbuf[100]; + while (1) + { + memset(sendbuf, 0, sizeof(sendbuf)); + cin>>sendbuf; + int ret=send(conn, sendbuf, strlen(sendbuf),0); //发送 + //输入exit或者对端关闭时结束 + if(strcmp(sendbuf,"exit")==0||ret<=0) + break; + } +} +``` + +接收线程负责接收服务器发来的消息并且打印到终端,因为我们要保证用户输入 exit 之后结束发送线程和接收线程再退出子线程,发送线程的结束很容易,输入 exit 之后 break 即可,但是接收线程无法得知用户是否输入了 exit,因此我们需要进行以下处理:服务器收到 exit 之后断开与客户端的连接,使得客户端接收线程的 recv 返回值为 0,这时再 break 即可退出接收线程: + +```cpp +//注意,前面不用加static! +void client::RecvMsg(int conn){ + //接收缓冲区 + char buffer[1000]; + //不断接收数据 + while(1) + { + memset(buffer,0,sizeof(buffer)); + int len = recv(conn, buffer, sizeof(buffer),0); + //recv返回值小于等于0,退出 + if(len<=0) + break; + cout<<"收到服务器发来的信息:"< 类型,初始化的时候就为其分配一定大小的空间,并全部置为 false 表示“未打开”。 + +更改后的 `server.h` 如下: + +```cpp +#ifndef SERVER_H +#define SERVER_H + +#include "global.h" + +class server{ + private: + int server_port; + int server_sockfd; + string server_ip; + static vector sock_arr;//改为了静态成员变量,且类型变为vector + public: + server(int port,string ip); + ~server(); + void run(); + static void RecvMsg(int conn); +}; +#endif +``` + +在 `server.cpp` 中开头加入下面这句代码为 `sock_arr` 完成初始化: + +```cpp +vector server::sock_arr(10000,false); //将10000个位置都设为false,sock_arr[i]=false表示套接字描述符i未打开(因此不能关闭) +``` + +当然,具体的大小设为 10000 还是其它数字取决于系统能够打开的文件描述符数量,在 Linux 中我们可以使用 `ulimit -n` 命令来查看和修改文件描述符数量限制。 + +![图片描述](https://typoraflykhan.oss-cn-beijing.aliyuncs.com/202304031032851.png) + +接下来添加服务器收到 exit 关闭套接字描述符的代码,修改后的 `server::RecvMsg` 如下: + +```cpp +//注意,前面不用加static! +void server::RecvMsg(int conn){ + //接收缓冲区 + char buffer[1000]; + //不断接收数据 + while(1) + { + memset(buffer,0,sizeof(buffer)); + int len = recv(conn, buffer, sizeof(buffer),0); + //客户端发送exit或者异常结束时,退出 + if(strcmp(buffer,"exit")==0 || len<=0){ + close(conn); + sock_arr[conn]=false; + break; + } + cout<<"收到套接字描述符为"<> sendbuf; // 从cin输入流中读取用户输入的信息,将其存放到sendbuf中。 + int ret = send(conn, sendbuf, strlen(sendbuf), 0); // send函数将sendbuf中的内容发送给服务器,其中conn参数表示连接套接字。如果发送成功,send函数返回发送的字节数 + if (strcmp(sendbuf, "exit") == 0 || ret <= 0) // ret <= 0表示发送失败 + break; + } +} + +// 从服务器接收消息 +void client::RecvMsg(int conn) +{ + // 接收缓冲区 + char buffer[1000]; + // 不断接收数据 + while (1) + { + memset(buffer, 0, sizeof(buffer)); + int len = recv(conn, buffer, sizeof(buffer), 0); + // recv 返回值小于等于 0,退出 + if (len <= 0) + break; + cout << "收到服务器发来的信息:" << buffer << endl; + } +} \ No newline at end of file diff --git a/Experiment_3/client.h b/Experiment_3/client.h new file mode 100644 index 0000000..52879e5 --- /dev/null +++ b/Experiment_3/client.h @@ -0,0 +1,20 @@ +#ifndef CLIENT_H +#define CLIENT_H + +#include "global.h" + +class client +{ +private: + int server_port; // 服务器端口号 + string server_ip; // 服务器 ip + int sock; // 与服务器建立连接的套接字描述符 +public: + client(int port, string ip); + ~client(); + void run(); // 启动客户端服务 + static void SendMsg(int conn); // 发送线程 + static void RecvMsg(int conn); // 接收线程 +}; + +#endif \ No newline at end of file diff --git a/Experiment_3/global.h b/Experiment_3/global.h new file mode 100644 index 0000000..a0430ac --- /dev/null +++ b/Experiment_3/global.h @@ -0,0 +1,20 @@ +#ifndef _GLOBAL_H +#define _GLOBAL_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + +#endif \ No newline at end of file diff --git a/Experiment_3/makefile b/Experiment_3/makefile new file mode 100644 index 0000000..af56caf --- /dev/null +++ b/Experiment_3/makefile @@ -0,0 +1,36 @@ +CXX = g++ +CXXFLAGS = -Wall -Wextra -g + +SRCS_TEST_SERVER = test_server.cpp server.cpp +SRCS_TEST_CLIENT = test_client.cpp client.cpp + +OBJS_TEST_SERVER = $(SRCS_TEST_SERVER:.cpp=.o) +OBJS_TEST_CLIENT = $(SRCS_TEST_CLIENT:.cpp=.o) + +TEST_SERVER_TARGET = ./test/test_server +TEST_CLIENT_TARGET = ./test/test_client + +.PHONY: all clean start_server start_client + +all: test_server test_client + +test_server: $(OBJS_TEST_SERVER) + $(CXX) $(CXXFLAGS) $^ -o $(TEST_SERVER_TARGET) -lpthread + +test_client: $(OBJS_TEST_CLIENT) + $(CXX) $(CXXFLAGS) $^ -o $(TEST_CLIENT_TARGET) -lpthread + +%.o: %.cpp + $(CXX) $(CXXFLAGS) -c $< -o $@ + +clean: + rm -f $(OBJS_TEST_SERVER) $(OBJS_TEST_CLIENT) $(TEST_SERVER_TARGET) $(TEST_CLIENT_TARGET) + +# clean: +# rm -rf *.o ./test/* + +start_server: test_server + $(TEST_SERVER_TARGET) + +start_client: test_client + $(TEST_CLIENT_TARGET) diff --git a/Experiment_3/server.cpp b/Experiment_3/server.cpp new file mode 100644 index 0000000..fd03861 --- /dev/null +++ b/Experiment_3/server.cpp @@ -0,0 +1,101 @@ +#include "server.h" + +// 在定义时,sock_arr 被初始化为一个大小为 1000 的容器,并使用 false 作为默认元素值(表示未建立连接)。这意味着,当程序开始运行时,sock_arr 中包含了 1000 个初始值为 false 的元素,可以用于记录当前所有连接套接字的状态。 +// 这个静态成员变量的作用可能是用于记录服务器上所有连接套接字的状态。具体来说,当一个新的连接套接字建立时,程序会向 sock_arr 中添加一个新的 bool 元素,表示该套接字处于“已连接”状态。当该套接字关闭时,程序会将相应的 bool 元素设置为 false,从而更新套接字的状态。 +vector server::sock_arr(1000, false); + +// 构造函数 +server::server(int port, string ip) : server_port(port), server_ip(ip) {} + +// 析构函数 +server::~server() +{ + for (int i = 0; i < sock_arr.size(); i++) // 找到处于连接状态的客户端套接字并关闭它们 + { + if (sock_arr[i]) + close(i); + } + close(server_sockfd); // 关闭服务器套接字 +} + +// 服务器开始服务 +void server::run() +{ + // 定义 sockfd + server_sockfd = socket(AF_INET, SOCK_STREAM, 0); + + // 定义地址信息 + struct sockaddr_in server_sockaddr; // 套接字地址结构 + server_sockaddr.sin_family = AF_INET; // TCP/IP 协议族 + server_sockaddr.sin_port = htons(server_port); // server_port 端口号 + server_sockaddr.sin_addr.s_addr = inet_addr(server_ip.c_str()); // ip 地址,127.0.0.1 是环回地址,相当于本机 ip + + // bind,成功返回 0,出错返回 -1 + if (bind(server_sockfd, (struct sockaddr *)&server_sockaddr, sizeof(server_sockaddr)) == -1) // 服务端套接字绑定 ip 地址和 port 端口号 + { + perror("bind"); // 输出错误原因 + exit(1); // 结束程序 + } + + // listen,成功返回 0,出错返回 -1 + if (listen(server_sockfd, 20) == -1) + { + perror("listen"); // 输出错误原因 + exit(1); // 结束程序 + } + + // 客户端套接字 + struct sockaddr_in client_addr; + socklen_t length = sizeof(client_addr); + + // 不断取出新连接并创建子线程为其服务 + while (1) + { + int conn = accept(server_sockfd, (struct sockaddr *)&client_addr, &length); // server_sockfd 是服务器端监听套接字的文件描述符,client_addr 是一个结构体指针,存储了客户端的地址信息(IP 地址和端口号),length 是客户端地址结构体的长度。 + if (conn < 0) + { + perror("connect"); // 输出错误原因 + exit(1); // 结束程序 + } + + cout << "文件描述符为" << conn << "的客户端成功连接\n"; + sock_arr.push_back(conn); // 当一个新的连接套接字建立时,程序会将其添加到 sock_arr 中。这样做可以方便地管理多个连接套接字,并在需要时对它们进行遍历、筛选等操作。 + // 创建线程 + thread t(server::RecvMsg, conn); + t.detach(); // 置为分离状态,不能用 join ,join 会导致主线程阻塞 + } +} + +// 定义子线程工作的静态函数:用于在子线程中处理客户端的数据收发,conn 参数是与客户端建立连接后返回的新套接字描述符,用于指定当前处理的客户端。 +// 注意:头文件 server.h 中已经定义过静态函数时,在另一个文件或同一个文件中包含该头文件并使用该静态函数时,不需要再次添加 static 关键字。如果重复添加 static 关键字,则会导致编译错误。 +void server::RecvMsg(int conn) +{ + // 接收缓冲区 + char buffer[1000]; + // 不断接收数据 + while (1) + { + memset(buffer, 0, sizeof(buffer)); // 在网络编程中,由于发送和接收的数据可能会出现不完整的情况,因此需要先将接收缓冲区清空,以避免旧数据对新数据造成干扰。在这段代码中,使用 memset() 函数将 buffer 清空,以确保该缓冲区为空,可以安全地存储新的接收数据。 + int len = recv(conn, buffer, sizeof(buffer), 0); + // 客户端发送 exit 或者异常结束时,退出 + if (strcmp(buffer, "exit") == 0 || len <= 0) + { + cout << "套接字描述符为" << conn << "的客户端断开了连接" << endl; + sock_arr[conn] = false; // 将 sock_arr[conn] 的值设置为 false,表示这个套接字已经不再处于连接状态了 + break; + } + + cout << "收到套接字描述符为" << conn << "发来的信息:" << buffer << endl; + + // 回复客户端 + string ans = "收到"; + int ret = send(conn, ans.c_str(), ans.length(), 0); // ans.c_str():将这个字符串作为参数传递给某个 C 语言库函数时,通常需要将其转换为 C 风格的字符串,即一个以空字符结尾的字符数组 + // 服务器收到 exit 或者异常关闭套接字描述符 + if (ret <= 0) // 当函数 RecvMsg 从连接套接字中读取到的数据长度为 0 或者小于 0 时,说明当前连接已经被关闭或出现了错误 + { + close(conn); // 关闭该连接套接字,释放相关资源 + sock_arr[conn] = false; // 将 sock_arr 容器中对应的元素值设置为 false,表示该套接字不再处于连接状态 + break; // 跳出循环,结束 RecvMsg 函数的执行 + } + } +} \ No newline at end of file diff --git a/Experiment_3/server.h b/Experiment_3/server.h new file mode 100644 index 0000000..70d769b --- /dev/null +++ b/Experiment_3/server.h @@ -0,0 +1,20 @@ +#ifndef SERVER_H +#define SERVER_H + +#include "global.h" + +class server +{ +private: + int server_port; // 服务器端口号 + int server_sockfd; // 设为 listen 状态的套接字描述符 + string server_ip; // 服务器 ip + static vector sock_arr; // 当一个新的连接套接字建立时,程序会向sock_arr中添加一个新的bool元素,表示该套接字处于“已连接”状态。当该套接字关闭时,程序会将相应的bool元素设置为false,从而更新套接字的状 +public: + server(int port, string ip); // 构造函数 + ~server(); // 析构函数 + void run(); // 服务器开始服务 + static void RecvMsg(int conn); // 子线程工作的静态函数:用于在子线程中处理客户端的数据收发,conn 参数是与客户端建立连接后返回的新套接字描述符,用于指定当前处理的客户端。 +}; + +#endif \ No newline at end of file diff --git a/Experiment_3/test_client.cpp b/Experiment_3/test_client.cpp new file mode 100644 index 0000000..9bfa8ed --- /dev/null +++ b/Experiment_3/test_client.cpp @@ -0,0 +1,7 @@ +#include "client.h" + +int main() +{ + client clnt(8023, "127.0.0.1"); // 创建了一个名为 clnt 的客户端对象,该对象的构造函数参数分别为服务器的端口号 8023 和 IP 地址 "127.0.0.1",表示要连接的服务器的地址和端口 + clnt.run(); // 调用了 clnt 对象的 run 函数,开始执行客户端程序 +} \ No newline at end of file diff --git a/Experiment_3/test_server.cpp b/Experiment_3/test_server.cpp new file mode 100644 index 0000000..d55b306 --- /dev/null +++ b/Experiment_3/test_server.cpp @@ -0,0 +1,7 @@ +#include "server.h" + +int main() +{ + server serv(8023, "127.0.0.1"); // 创建了一个名为serv的服务器对象,该对象的构造函数参数分别为服务器的端口号8023和IP地址"127.0.0.1",表示要监听的服务器的地址和端口 + serv.run(); // 调用了serv对象的run函数,开始执行服务器程序并等待客户端连接 +} \ No newline at end of file