0%

Linux下的C++项目开发:聊天室(一)


Linux C++ 开发常用工具

使用g++编译代码

C++编译过程

C++的编译过程主要有四个阶段:预处理、编译、汇编、链接:

  • 预处理:主要负责宏定义的替换、条件编译(防止重复包含头文件的宏#ifdef、#ifndef、#else、#endif等)、将include的头文件展开到正文等;
  • 编译:负责将源代码转化为汇编代码;
  • 汇编:负责将汇编代码转化为可重定位的目标二进制文件;
    • 静态重定位:即在程序装入内存的过程中完成,是指在程序开始运行前,程序中的各个地址有关的项均已完成重定位,地址变换通常是在装入时一次完成的,以后不再改变,故称为静态重定位。
    • 动态重定位:它不是在程序装入内存时完成的,而是CPU每次访问内存时 由动态地址变换机构(硬件)自动进行把相对地址转换为绝对地址。动态重定位需要软件和硬件相互配合完成。
  • 链接:负责将所有的目标文件(二进制目标文件、库文件等)连接起来,进行符号解析和重定位,最后生成可执行文件。静态链接和动态链接两者最大的区别就在于链接的时机不一样。
    • 静态链接:将多个源文件产生的目标文件进行链接,从而形成一个可以执行的程序,静态库也可以简单地看成是一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。静态链接是在形成可执行程序前。
    • 动态链接:解决静态链接的两个问题,空间浪费和更新困难。动态链接把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。动态链接的进行则是在程序执行时。

g++编译命令

假设有test.cpp文件:

1
2
3
4
5
6
7
8
9
10
#include<iostream>
using namespace std;
int add(int a,int b){
return a+b;
}
int main(){
int a=1,b=2;
cout<<add(a,b)<<endl;
return 0;
}

预处理阶段的结果,直接在终端输出,命令为:

1
g++ -E test.cpp

编译阶段可以得到汇编代码(.s 文件),可由以下命令直接得到汇编代码文件:

1
g++ -S test.cpp

可重定位的目标文件(.o 文件)由以下命令直接得到:

1
g++ -c test.cpp

需要得到最终的可执行文件时可使用下面这条命令,需要自己指定目标文件名,下面这条命令最终得到名为 work 的可执行文件:

1
2
g++ -o work test.cpp
g++ test.cpp -o work

两种写法都可以,注意命令中-o后面是生成可执行文件的名字。

在实际开发过程中,往往需要使用 gdb 进行调试,可以在编译时加上-g 选项,如下:

1
g++ -g -o work test.cpp

如果在开发过程中加入自己指定使用的动态链接库,比如我们需要使用 pthread 线程库,需要加上 -l 选项。下例中编译器会到 lib 目录下找 libpthread.so 文件:

1
g++ -o work test.cpp -lpthread

多文件编译

多个源码文件进行一起编译,例如有三个cpp文件:a.cpp、b.h、b.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// a.cpp
#include "b.h"
extern int add(int,int);
int main(){
int a=1,b=2;
cout<<add(a,b);
return 0;
}

// b.h
#ifndef BH
#define BH

#include<iostream>
using namespace std;
int add(int,int);

#endif

// b.cpp
#include "b.h"
int add(int a,int b){
return a+b;
}

可以采用以下的编译命令:

1
g++ -o test a.cpp b.cpp

使用以下命令运行程序:

1
./test

运行可执行文件

使用 g++ 编译得到的可执行文件,使用如下格式执行:

1
./可执行文件名

如果程序需要传入参数,则以下述格式执行:

1
./可执行文件名 参数1 参数2

使用gdb进行调试

安装gdb

安装分为编译安装和 apt 命令安装,因为 Ubuntu 的软件包仓库内置了 gdb,所以可以直接使用命令安装。

1
sudo apt install gdb

安装成功后查看gdb版本

1
gdb -v

gdb常用调试命令

为了能使用 gdb 调试,在用 g++ 编译时要将 -g 参数加上,然后进行gdb调试:

1
2
g++ -g -o test a.cpp b.cpp
gdb test

常用的调试命令:

  • l:查看代码
  • b 5:在程序的第 5 行添加断点
  • info break:查看断点
  • r:开始运行
  • s:进入函数内部
  • n:进入下一步
  • finish:跳出函数内部
  • c:运行到下一个断点

最后退出调试,可以直接输入 quit 命令。

编写 makefile 进行自动编译

在大型项目中有大量的源代码文件,不可能每次都逐个敲 g++ 命令来进行编译,而是采用编写 makefile 的方式来进行自动编译,提高效率。

创建makefile

和创建源代码文件一样,可以直接用 vi 编辑器来创建 makefile:

1
vi makefile

写好 makefile 内容后,命令模式输入 wq 保存离开即可。

makefile基本格式

一般格式为:

1
2
3
4
目标名1:依赖文件1,依赖文件2,依赖文件3
g++ 编译命令
目标名2:依赖文件4,依赖文件5
g++ 编译命令

其中目标名可以由自己定义,也可以是一个文件的名字;依赖文件就是说要达成这个目标所需要的文件。 仍以前面的“多文件编译”代码为例,可以写出如下的 makefile 文件(注意:makefile 文件中要使用 tab 键,不能使用空格键,否则会报错):

1
2
3
4
target:a.cpp b.o
g++ -o test a.cpp b.o
b.o:b.cpp
g++ -c b.cpp

target 依赖于 a.cppb.o,而 b.o 依赖于 b.cpp,因此编译时发现 b.cpp 更新了的话就会先执行后面的命令来更新 b.o。保存好 makefile 文件之后,我们用命令行输入 make 即可进行自动编译:

快速清理目标文件

有时候我们想要删掉 makefile 产生出来的所有目标文件,如果逐个去删显得过于麻烦,因此我们可以借助 make clean。仍然是在前面的 makefile 文件中修改,在后面补上一个 clean:,以及相应的清除命令:

1
2
3
4
5
6
7
8
target:a.cpp b.o
g++ -o test a.cpp b.o
b.o:b.cpp
g++ -c b.cpp
.PHONY:clean
clean:
rm *.o
rm test

在命令行执行 make clean 就可删掉所有目标文件。

TCP通信

传输层基本概念

TCP/IP 四层参考模型中,从上往下有四种层次:应用层、传输层、网络层、网络接口层,应用层包括 HTTP、FTP、DNS 等协议,而传输层包括 TCP、UDP 两种协议,网络层则包含 IP、ARP 等协议,网络接口层较为底层,一般不是我们研究的对象。其中,传输层是我们在编程开发中较为重要的一层,需要对其中的两种协议尤其是 TCP 理解透彻。

图片描述

传输层的作用

根本目的:在网络层提供的数据通信服务基础上,实现主机的进程间通信的可靠服务。
主要有以下两个要点:为位于两个主机内部的两个应用进程之间提供通信服务、提供可靠的通信服务。

套接字

“套接字”表示一个 IP 地址与对应的一个端口号。例如,一个 IP 地址为 172.31.75.8 的客户端使用 8050 端口号,那么标识客户端的套接字为“172.31.75.8:8050”。

端口号

端口号为 0-65535 之间的整数,有3种类型:熟知端口号、注册端口号、临时端口号。

  • 熟知端口号:给每种服务器分配确定的全局端口号。每个用户进程都知道相应的服务器进程的熟知端口号,范围为 0-1023,它是统一分配和控制的。

  • 注册端口号:在 IANA 注册的端口号,数值范围为 1024-49151。

  • 临时端口号:客户端程序使用临时端口号,它是运行在客户端上的 TCP/IP 软件随机选取的,范围为 49152-65535。

平时进行网络编程时服务器最好使用注册端口号,而客户端的端口号则是系统随机分配的,即临时端口号。

UDP用户数据协议

UDP协议的特点:

  • 无连接的:发送数据之前不需要建立连接,因此减少了开销和发送数据之前的时延。
  • 尽最大努力交付:即不保证可靠交付,因此主机不需要维持复杂的连接状态表。
  • 面向报文的:UDP 对应用层传递下来的报文,既不合并,也不拆分,而是保留这些报文的边界。UDP 对于应用程序提交的报文,添加头部后就向下提交给网络层。
  • 没有拥塞控制:网络出现的拥塞时,UDP 不会使源主机的发送速率降低。这对某些实时应用是很重要的,很适合多媒体通信的要求。
  • 持多对多的交互通信。

UDP的适用场景:

  • 适用于少量(几百个字节)的数据。
  • 对性能的要求高于对数据完整性的要求,如视频播放、P2P、DNS 等。
  • 需要“简短快捷”的数据交换 简单的请求与应答报文交互,如在线游戏。
  • 需要多播和广播的应用,源主机以恒定速率发送报文,拥塞发生时允许丢弃部分报文,如本地广播、隧道 VPN。

TCP传输控制协议

TCP协议的特点:

  • 面向连接的传输服务。打电话式、会话式通信。
  • 面向字节流传输服务(而 UDP 是面向报文)。字节管道、字节按序传输和到达。
  • 全双工通信。一个应用进程可以同时接收和发送数据、捎带确认;通信双方都设置有发送和接收缓冲区,应用程序将要发送的数据字节提交给发送缓冲区,实际发送由 TCP 协议控制,接收方收到数据字节后将它存放在接收缓冲区,等待高层应用程序读取。
  • 可建立多个并发的 TCP 连接。如 Web 服务器可同时与多个客户端建立的连接会话。
  • 可靠传输服务。不丢失数据、保持数据有序、向上层不重复提交数据(通过确认机制、拥塞控制等方式实现), 想象一下 ATM 机转帐应用就需要上述可靠性。

TCP的报文结构图:

图片描述

重点关注的标志位:

标志 说明
SYN 当SYN=1,ACK=0,是一个建立连接请求的报文;
当SYN=1,ACK=1,是一个同意建立连接请求的报文;
ACK 当ACK=1时,确认序号字段才有意义
FIN FIN=1说明数据发送完毕,请求释放连接
RST RST=1说明出现严重错误,必须释放连接再重新建立连接
URG URG=1说明此报文是紧急数据,要尽快传送出去
PSH PSH=1请求接收方TCP软件将该报文立即推送给应用程序

TCP 连接包括连接建立、报文传输、连接释放三个阶段,其中连接建立的三次握手过程较为重要。

建立连接的三次握手过程:

(1)当客户端准备发起一次 TCP 连接,首先向服务器发送第一个“SYN”报文(控制位 SYN=1)。

(2)服务器收到 SYN 报文后,如果同意建立连接,则向客户端发送第二个“SYN+ACK”报文(控制位 SYN=1,ACK=1),该报文表示对第一个 SYN 报文请求的确认。

(3)接收到 SYN+ACK 报文后,客户端发送第三个 ACK 报文,表示对 SYN+ACK 报文的确认。

图片描述

TCP套接字网络编程

网络编程一般采用C/S架构,即服务器端和客户端。

服务器端

服务器端一般先用 socket 创建一个套接字,然后用 bind 给这个套接字绑定地址(即 ip+端口号),然后调用 listen 把这个套接字置为监听状态,随后调用 accept 函数从已完成连接队列中取出成功建立连接的套接字,以后就在这个新的套接字上调用 send、recv 来发送数据、接收数据,最后调用 close 来断开连接释放资源即可。整个过程如下:

图片描述

客户端

与服务器不同,客户端并不需要 bind 绑定地址,因为端口号是系统自动分配的,而且客户端也不需要设置监听的套接字,因此也不需要 listen。客户端在用 socket 创建套接字后直接调用 connect 向服务器发起连接即可,connect 函数通知 Linux 内核完成 TCP 三次握手连接,最后把连接的结果作为返回值。成功建立连接后就可以调用 send 和 recv 来发送数据、接收数据,最后调用 close 来断开连接释放资源。整个过程如下:

图片描述

完整流程

TCP网络编程的完整流程如下:

图片描述

TCP相关的数据结构

地址结构:

1
2
3
4
5
6
7
8
9
10
struct sockaddr_in {
short int sin_family; /* 地址族 */
unsigned short int sin_port; /* 端口号 */
struct in_addr sin_addr; /* ip地址 */
unsigned char sin_zero[8];
};

struct in_addr {
unsigned long s_addr;
};

TCP网络编程函数:

socket函数:

1
2
3
4
5
6
7
8
9
10
int socket( int domain, int type,int protocol)
/*
功能:创建一个新的套接字,返回套接字描述符
参数说明:
domain:域类型,指明使用的协议栈,如TCP/IP使用的是PF_INET,其他还有AF_INET6、AF_UNIX
type:指明需要的服务类型, 如
SOCK_DGRAM:数据报服务,UDP协议
SOCK_STREAM:流服务,TCP协议
protocol:一般都取0(由系统根据服务类型选择默认的协议)
*/

bind函数:

1
2
3
4
5
6
7
8
9
int bind(int sockfd,struct sockaddr* my_addr,int addrlen)
/*
功能:为套接字绑定地址
TCP/IP协议使用sockaddr_in结构,包含IP地址和端口号,服务器使用它来指明熟知的端口号,然后等待连接
参数说明:
sockfd:套接字描述符,指明创建连接的套接字
my_addr:本地地址,IP地址和端口号
addrlen:地址长度
*/

listen函数:

1
2
3
4
5
6
7
8
int listen(int sockfd,int backlog)
/*
功能:
将一个套接字置为监听模式,准备接收传入连接。用于服务器,指明某个套接字连接是被动的监听状态。
参数说明:
Sockfd:套接字描述符,指明创建连接的套接字
backlog: linux内核2.2之前,backlog参数=半连接队列长度+已连接队列长度;linux内核2.2之后,backlog参数=已连接队列(Accept队列)长度
*/

accept函数:

1
2
3
4
5
6
7
8
int accept(int sockfd, structsockaddr *addr, int *addrlen)
/*
功能:从已完成连接队列中取出成功建立连接的套接字,返回成功连接的套接字描述符。
参数说明:
Sockfd:套接字描述符,指明正在监听的套接字
addr:提出连接请求的主机地址
addrlen:地址长度
*/

send函数:

1
2
3
4
5
6
7
8
9
int send(int sockfd, const void * data, int data_len, unsigned int flags)
/*
功能:在TCP连接上发送数据,返回成功传送数据的长度,出错时返回-1。send会将数据移到发送缓冲区中。
参数说明:
sockfd:套接字描述符
data:指向要发送数据的指针
data_len:数据长度
flags:通常为0
*/

recv函数:

1
2
3
4
5
6
7
8
9
int recv(int sockfd, void *buf, intbuf_len,unsigned int flags)
/*
功能:接收数据,返回实际接收的数据长度,出错时返回-1。
参数说明:
Sockfd:套接字描述符
Buf:指向内存块的指针
Buf_len:内存块大小,以字节为单位
flags:一般为0
*/

close函数:

1
2
3
4
5
6
close(int sockfd)
/*
功能:撤销套接字。如果只有一个进程使用,立即终止连接并撤销该套接字,如果多个进程共享该套接字,将引用数减一,如果引用数降到零,则关闭连接并撤销套接字。
参数说明:
sockfd:套接字描述符
*/

connect函数:

1
2
3
4
5
6
7
8
int connect(int sockfd,structsockaddr *server_addr,int sockaddr_len)
/*
功能: 同远程服务器建立主动连接,成功时返回0,若连接失败返回-1。
参数说明:
Sockfd:套接字描述符,指明创建连接的套接字
Server_addr:指明远程端点:IP地址和端口号
sockaddr_len :地址长度
*/

服务器与客户端连接实例

编写两个程序:一个服务器与一个客户端,用户可以在客户端不断输入信息并发送到服务器终端上显示。

服务器程序编写

编写server.cpp, 代码如下:

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
// server.cpp
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/shm.h>
#include <iostream>
using namespace std;

int main()
{
//定义sockfd
int server_sockfd = socket(AF_INET,SOCK_STREAM, 0);

//定义sockaddr_in
struct sockaddr_in server_sockaddr;
server_sockaddr.sin_family = AF_INET;//TCP/IP协议族
server_sockaddr.sin_port = htons(8023);//端口号
server_sockaddr.sin_addr.s_addr = inet_addr("127.0.0.1");//ip地址,127.0.0.1是环回地址,相当于本机ip

//bind,成功返回0,出错返回-1
if(bind(server_sockfd,(struct sockaddr *)&server_sockaddr,sizeof(server_sockaddr))==-1)
{
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);

//成功返回非负描述字,出错返回-1
int conn = accept(server_sockfd, (struct sockaddr*)&client_addr, &length);
if(conn<0)
{
perror("connect");//输出错误原因
exit(1);//结束程序
}
cout<<"客户端成功连接\n";

//接收缓冲区
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)
break;
cout<<"收到客户端信息:"<<buffer<<endl;
}
close(conn);
close(server_sockfd);
return 0;
}

客户端程序编写

编写client.cpp, 代码如下:

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
// client.cpp
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/shm.h>
#include <iostream>
using namespace std;

int main()
{
//定义sockfd
int sock_cli = socket(AF_INET,SOCK_STREAM, 0);

//定义sockaddr_in
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;//TCP/IP协议族
servaddr.sin_port = htons(8023); //服务器端口
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); //服务器ip

//连接服务器,成功返回0,错误返回-1
if (connect(sock_cli, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
{
perror("connect");
exit(1);
}
cout<<"连接服务器成功!\n";

char sendbuf[100];
char recvbuf[100];
while (1)
{
memset(sendbuf, 0, sizeof(sendbuf));
cin>>sendbuf;
send(sock_cli, sendbuf, strlen(sendbuf),0); //发送
if(strcmp(sendbuf,"exit")==0)
break;
}
close(sock_cli);
return 0;
}

测试运行

编译两个cpp文件:

1
2
g++ -o server server.cpp
g++ -o client client.cpp

运行服务器程序:

1
./server

另开一个新的终端,运行客户端程序:

1
./client

连接成功,可以在客户端不断输入信息并且在服务器端显示。

多线程并发服务器

上一个实例练习编写的服务器是单线程的,只能为单个客户端服务。而要设计一个聊天室的服务器就必须能够同时为多个客户端服务,因此需要将服务器升级为多线程版本。

进程与线程的基本概念

  • 进程:是程序的一次执行过程,是操作系统资源分配的基本单位。

    比如在实例中运行./server服务器程序,就会产生一个进程,可以使用 ps -ef|grep ./server 命令查看相关进程快照。其中的PID就是该进程的进程号。

  • 线程:是任务调度和执行的基本单位,一个进程中可以有多个线程独立运行。线程没有自己独立的地址空间,会与其它属于同一进程的线程一起共享进程的资源,但是每个线程也会有自己的独立的栈和一组寄存器。在 Linux 当中,线程的实现比较特别,会把线程当做进程来实现,即将线程视为一个与其它进程共享资源的进程。

    可以使用 ps -T -p XXX(进程号) 命令来查看一个进程的所有线程。

C++11的thread线程库

基本使用

C++11中提供了专门的线程库,可以很方便地进行调用。

使用需要导入头文件:#include<thread>

创建一个新线程来执行 run 函数:

1
thread t(run);  //实例化一个线程对象t,让该线程执行run函数,构造对象后线程就开始执行了

假如 run 函数需要传入参数 a 和 b,可以这样构造:

1
thread t(run,a,b);  //实例化一个线程对象t,让该线程执行run函数,传入a和b作为run的参数

需要注意的是,传入的函数必须是全局函数或者静态函数,不能是类的普通成员函数。

join 函数会阻塞主线程,直到 join 函数的 thread 对象标识的线程执行完毕为止,join 函数使用方法如下:

1
t.join(); //调用join后,主线程会一直阻塞,直到子线程的run函数执行完毕

但有时候需要主线程在继续完成其它的任务,而不是一直等待子线程结束,这时候我们可以使用 detach 函数。detach 函数会让子线程变为分离状态,主线程不会再阻塞等待子线程结束,而是让系统在子线程结束时自动回收资源。使用的方法如下:

1
t.detach();

代码实例

构建两个线程,同时输出 1-10,实现简单的多线程编程:

新建一个 test_thread.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// test_thread.cpp
#include <iostream>
#include <thread>
#include <unistd.h>
using namespace std;
void print(){
for(int i=1;i<=10;i++){
cout<<i<<endl;
sleep(1); //休眠1秒钟
}
}
int main(){
thread t1(print),t2(print);
t1.join();
t2.join();
//也可以使用detach
//t1.detach();
//t2.detach();
}

然后使用 g++ 进行编译,需要注意的是这里要用上 -l 来链接线程动态库,如下:

1
g++ -o test_thread test_thread.cpp -lpthread

实例:多线程并发服务器

具体要求

编写两个程序:一个多线程服务器、一个单线程客户端程序(可和上个实验一样),用一个终端运行服务器程序,多个终端运行客户端程序,让所有客户端发送的信息都能在服务器终端上显示。

  1. 编写一个服务器类 server,该类可以创建多个线程为多个客户端服务,接收所有客户端发送的消息并打印出来。
  2. 要编写多个源代码文件:server.h 头文件给出 server 类声明、server.cpp 给出类方法具体实现、test_server.cpp 中编写主函数创建 server 实例对象并测试。
  3. 客户端程序可继续使用上个实验的,不用做修改。
  4. 编写 Makefile 进行自动编译,使用 git 管理版本。

设计思路

因为需要为每个客户端创建一个线程进行服务,所以我们要在每次 accept 取出新连接之后都创建一个线程,这个线程只负责服务这个新的连接,因此我们还要将这个连接对应的套接字描述符传入线程函数中。线程函数不断地调用 recv 接收信息并打印,直到收到客户端发来的 “exit” 或者 recv 返回值小于等于 0 为止。

实现过程

编写 server.h 头文件,给出类的成员变量和成员函数声明:

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
// server.h
#ifndef SERVER_H
#define SERVER_H

#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/shm.h>
#include <iostream>
#include <thread>
#include <vector>
using namespace std;
class server{
private:
int server_port;//服务器端口号
int server_sockfd;//设为listen状态的套接字描述符
string server_ip;//服务器ip
vector<int> sock_arr;//保存所有套接字描述符
public:
server(int port,string ip);//构造函数
~server();//析构函数
void run();//服务器开始服务
static void RecvMsg(int conn);//子线程工作的静态函数
};

#endif

server.cpp 文件中给出函数具体的定义:

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
// server.cpp
#include "server.h"

//构造函数
server::server(int port,string ip):server_port(port),server_ip(ip){}

//析构函数
server::~server(){
for(auto conn:sock_arr)
close(conn);
close(server_sockfd);
}

//服务器开始服务
void server::run(){
//定义sockfd
server_sockfd = socket(AF_INET,SOCK_STREAM, 0);

//定义sockaddr_in
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)
{
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);
if(conn<0)
{
perror("connect");//输出错误原因
exit(1);//结束程序
}
cout<<"文件描述符为"<<conn<<"的客户端成功连接\n";
sock_arr.push_back(conn);
//创建线程
thread t(server::RecvMsg,conn);
t.detach();//置为分离状态,不能用join,join会导致主线程阻塞
}
}

//子线程工作的静态函数
//注意,前面不用加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)
break;
cout<<"收到套接字描述符为"<<conn<<"发来的信息:"<<buffer<<endl;
}
}

编写主函数构建实例进行测试,test_server.cpp 文件:

1
2
3
4
5
6
// test_server.cpp
#include"server.h"
int main(){
server serv(8023,"127.0.0.1");//创建实例,传入端口号和ip作为构造函数参数
serv.run();//启动服务
}

利用makefile进行自动编译,makefile内容如下:

1
2
3
4
test_server: test_server.cpp server.cpp server.h
g++ -o test_server test_server.cpp server.cpp -lpthread
clean:
rm test_server

注意:makefile的缩进不能使用空格,要使用制表符(制表符长度4)。

多线程客户端

实例:多线程客户端

具体要求

将之前的单线程客户端升级为多线程客户端(一个线程用于接收并打印信息、一个线程用于输入并发送信息),为前面实验的多线程服务器添加自动回复客户端的代码,用一个终端运行服务器程序,多个终端运行客户端程序,多个客户端都能发送信息送达服务器并收到服务器的应答,并将应答打印到客户端终端上,当用户在客户端输入 exit 时,要结束两个线程之后再结束客户端进程。

  1. 编写一个客户端类 client ,有发送线程和接收线程,可以同时发送消息和接收消息。
  2. 要编写多个源代码文件:client 头文件给出 client 类声明、client.cpp 给出类方法具体实现、test_client.cpp 中编写主函数创建 client 实例对象并测试。
  3. 当用户在客户端输入 exit 时,要结束发送线程和接收线程之后才退出主线程。
  4. 服务器程序要在实验 3 的基础上进行一定修改,能够回复消息。
  5. 编写 Makefile 进行自动编译,使用 git 管理版本。

设计思路

客户端应先 connect 服务器建立连接,成功连接之后就创建发送线程和接收线程,与服务器类的设计同理,我们需要将发送线程和接收线程的函数设为静态成员函数,发送线程和接收线程中都使用 while(1) 的循环结构,循环终止的条件是用户输入了 exit 或者对端关闭了连接。

实现过程

考虑到 client 类和 server 类会用到许多相同的头文件,因此我们没必要每次都重新写各种头文件,我们可以编写一个 global.h,在里面写上所有我们需要的头文件(甚至全局变量),让 server.hclient.h 都引入这个 global.h 即可,这样通过 global.h 就可以包含所有头文件,没那么容易乱。global.h 文件内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef _GLOBAL_H
#define _GLOBAL_H

#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/shm.h>
#include <iostream>
#include <thread>
#include <vector>
using namespace std;

#endif

client.h 头文件中给出 client 类的成员变量和成员函数声明,该类有三个成员变量,同时有构造函数、析构函数、run 函数、发送线程函数、接收线程函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#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 和端口号,析构函数负责关闭套接字描述符:

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
#include "client.h"

client::client(int port,string ip):server_port(port),server_ip(ip){}
client::~client(){
close(sock);
}

// run 函数负责建立与服务器的连接并且启动发送线程和接收线程
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 或者出现异常时将结束线程
// 注意,前面不用加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;
}
}

// 接收线程负责接收服务器发来的消息并且打印到终端
// 注意,前面不用加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<<"收到服务器发来的信息:"<<buffer<<endl;
}
}

客户端发送线程的结束很容易,输入 exit 之后 break 即可,但是接收线程无法得知用户是否输入了 exit,因此我们需要进行以下处理:服务器收到 exit 之后断开与客户端的连接,使得客户端接收线程的 recv 返回值为 0,这时再 break 即可退出接收线程。

对服务器代码进行修改,让服务器收到exit后立即close掉套接字描述符,因此需要将sock_arr改为vector<bool>类型,初始化的时候就为其分配一定大小的空间,并全部置为 false 表示“未打开”。更改后的 server.h 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifndef SERVER_H
#define SERVER_H

#include "global.h"

class server{
private:
int server_port;
int server_sockfd;
string server_ip;
static vector<bool> sock_arr;//改为了静态成员变量,且类型变为vector<bool>
public:
server(int port,string ip);
~server();
void run();
static void RecvMsg(int conn);
};
#endif

server.cpp 中开头加入下面这句代码为 sock_arr 完成初始化:

1
vector<bool> server::sock_arr(10000,false);    //将10000个位置都设为false,sock_arr[i]=false表示套接字描述符i未打开(因此不能关闭)

当然,具体的大小设为 10000 还是其它数字取决于系统能够打开的文件描述符数量,在 Linux 中我们可以使用 ulimit -n 命令来查看和修改文件描述符数量限制。

接下来添加服务器收到 exit 关闭套接字描述符的代码,修改后的 server::RecvMsg 如下:

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
//注意,前面不用加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<<"收到套接字描述符为"<<conn<<"发来的信息:"<<buffer<<endl;
//回复客户端
string ans="收到";
int ret = send(conn,ans.c_str(),ans.length(),0);
//服务器收到exit或者异常关闭套接字描述符
if(ret<=0){
close(conn);
sock_arr[conn]=false;
break;
}
}
}

最后,我们需要将 server.cpp 的析构函数改为如下形式,来关闭仍处于打开状态的套接字描述符:

1
2
3
4
5
6
7
server::~server(){
for(int i=0;i<sock_arr.size();i++){
if(sock_arr[i])
close(i);
}
close(server_sockfd);
}

至此,对服务器的修改也完成了,我们接下来编写一个 test_client.cpp 文件来测试客户端:

1
2
3
4
5
#include"client.h"
int main(){
client clnt(8023,"127.0.0.1");
clnt.run();
}

接下来修改 makefile

1
2
3
4
5
6
7
8
9
10
all: test_server.cpp server.cpp server.h test_client.cpp client.cpp client.h global.h
g++ -o test_client test_client.cpp client.cpp -lpthread
g++ -o test_server test_server.cpp server.cpp -lpthread
test_server: test_server.cpp server.cpp server.h global.h
g++ -o test_server test_server.cpp server.cpp -lpthread
test_client: test_client.cpp client.cpp client.h global.h
g++ -o test_client test_client.cpp client.cpp -lpthread
clean:
rm test_server
rm test_client

接下来 make 并且进行测试:

1
2
3
4
5
6
make
./test_server
# 新开一个终端
./test_client
# 再新开一个终端
./test_client