26. Linux C/C++ TCP/UDP网络编程与高阶技巧

1.前言

上一章我们已经基本学会了Linux系统的基本使用方法,说白了就是需要记忆大量的命令,但也要讲究技巧。

而Linux系统应用范围最广的地方其实是服务器,因为Linux系统可以没有图形化界面,内核可以相当小,性能也非常好,最重要的是开源免费。

这些优势就导致,当今世界上绝大多数服务器,都是在Linux系统上开发的,而开发一个高性能的服务器,C/C++语言自然也就是不二选择。

也因此C/C++语言的一个大的就业方向就是Linux服务器开发。

2.建立项目

首先还是建立项目吧,想要高效率的开发软件,IDE肯定必不可少,所以还是采用VS开发。

还是Linux控制台项目:

image-20240103183421898

注意要打开ubuntu终端,开启ssh服务:

sudo service ssh start

这样才能正常写代码,否则VS里面会报一大堆错误,找不到文件什么的。

然后运行一下,如果能正常编译运行就没问题:

image-20240103183943069

如果出现VS报错,找不到文件什么的,这很正常,毕竟的通过网络传输实现的跨平台编程,不确定因素有很多。

所以一定要确保linux平台ssh服务是开启状态,并且VS可以正常连接上去。

可以先试着编译以下,只要编译没错误,就说明没问题,右键VS里面任意区域,选择重新扫描文件、项目,一般就能恢复识别。

3.Tcp编程

3.1 服务器

首先我们还是来写一个Tcp协议的聊天软件,还记得在Windows系统上如何写Tcp吗?

不记得了一定要返回重新看一看,因为网络编程的逻辑都差不多,所以我这里不会再重复赘余的讲解。

过程大致相同,但个人觉得,Linux开发起来更加简洁,首先就是在Linux系统上开发服务器,不需要加载动态库什么的,包含头文件就可以直接使用。

其次就是,Linux编程不像Windows那样,定义很多自定义类型。

首先看一下我们需要的头文件:

#include <cstdio> //等价于stdio.h,这是C++的命名规则
#include<sys/socket.h> //众多我们要使用的网络函数,比如bind,socket,connect等等
#include<arpa/inet.h> //一些我们会使用到的网络变量,比如地址sockaddr_in 这个结构体就在该头文件中
#include <unistd.h> //包含众多常见Linux API,比如本文要使用的close函数,因为socket在linxu里面也是一个文件,所以关闭文件统一使用close函数
#include<string.h> //包含一些基本的字符串操作函数,比如strcmp,比较字符串

有了上面的头文件,我们就可以来写一个服务器函数:

void Server() {
	int sockListen = socket(AF_INET, SOCK_STREAM, 0);
	sockaddr_in addr;
	addr.sin_family = AF_INET;
	addr.sin_port = htons(5000);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1");
	int ret = bind(sockListen, (sockaddr*)&addr, sizeof(addr));
	if (ret == -1) {
		printf("绑定失败!");
		return;
	}

	listen(sockListen, 5);

	sockaddr_in cliAddr;
	socklen_t len = sizeof(cliAddr);
	int clisock = accept(sockListen, (sockaddr*)&cliAddr, &len);
	if (clisock == -1) {
		printf("接收客户端错误");
		return;
	}

	printf("客户端已经连接!\n");

	while (1) {
		char buf[0xFF]{};
		size_t len=recv(clisock, buf, 0xFF, 0);
		if (len <= 0) {
			printf("客户端已经断开连接!\n");
			break;
		}
		send(clisock, buf, len, 0);
	}
	close(sockListen);
	close(clisock);
}

步骤基本与windows网络编程中相同,不同之处就是,不用自己加载网络库,关闭套接字不再使用closesocket,而是close函数。

注意,socket返回的是一个int值,因为linux遵循万物皆文件的思想,所以都是直接使用数字代表一个文件。

包括linux中的普通文件操作API等等都是如此,但由于C/C++语言是跨平台的,所以你完全可以继续使用以前的C/C++方式操作Linux系统中的文件。

还有一些新的类型,比如socklen_t 等等,还是老方法,在VS里面直接右键看其真实类型是什么即可,之所以要使用这个变量类型,单纯是因为后面的函数参数是这个类型。

上面的代码就实现了一个回声服务器的基本功能

所谓回声服务器,就是你发给服务器什么内容,服务器都会原封不动的发送回来

这就不需要你手动去服务器程序来给客户端发送消息了,这适用于测试网络是否能够正常通信。

3.2 客户端

上面写了一个服务器的函数,现在再来写一个客户端的函数,相比较而言会更加简单:

void Client() {
	int sockCli = socket(AF_INET, SOCK_STREAM, 0);
	sockaddr_in addr{};
	addr.sin_family = AF_INET;
	addr.sin_port = htons(5000);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1");

	int ret = connect(sockCli, (sockaddr*)&addr, sizeof(addr));
	if (ret == -1) {
		printf("连接服务器失败!");
		return;
	}

	char buf[0xFF];

	while (1) {
		printf("请输入信息:");
		scanf("%s",buf);
		send(sockCli, buf, strlen(buf), 0);
		memset(buf, 0, 0xFF);
		ssize_t len=recv(sockCli, buf, 0xFF, 0);
		printf("服务器:%d|%s\n", len,buf);
	}

	close(sockCli);
}

步骤基本都差不多,完成了Windows网络编程学习的,这种应该随便自己看看都能看懂,我也就不多说了。

3.3 完整代码

上面写了两个函数,而没有写在两个项目里面,就是为了方便看而已:

#include <cstdio>
#include<sys/socket.h>
#include<arpa/inet.h>
#include <unistd.h>
#include<string.h>
void Server() {
	int sockListen = socket(AF_INET, SOCK_STREAM, 0);
	sockaddr_in addr;
	addr.sin_family = AF_INET;
	addr.sin_port = htons(5000);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1");
	int ret = bind(sockListen, (sockaddr*)&addr, sizeof(addr));
	if (ret == -1) {
		printf("绑定失败!");
		return;
	}

	listen(sockListen, 5);

	sockaddr_in cliAddr;
	socklen_t len = sizeof(cliAddr);
	int clisock = accept(sockListen, (sockaddr*)&cliAddr, &len);
	if (clisock == -1) {
		printf("接收客户端错误");
		return;
	}

	printf("客户端已经连接!\n");

	while (1) {
		char buf[0xFF]{};
		size_t len=recv(clisock, buf, 0xFF, 0);
		if (len <= 0) {
			printf("客户端已经断开连接!\n");
			break;
		}
		send(clisock, buf, len, 0);
	}
	close(sockListen);
	close(clisock);
}

void Client() {
	int sockCli = socket(AF_INET, SOCK_STREAM, 0);
	sockaddr_in addr{};
	addr.sin_family = AF_INET;
	addr.sin_port = htons(5000);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1");

	int ret = connect(sockCli, (sockaddr*)&addr, sizeof(addr));
	if (ret == -1) {
		printf("连接服务器失败!");
		return;
	}

	char buf[0xFF];

	while (1) {
		printf("请输入信息:");
		scanf("%s",buf);
		send(sockCli, buf, strlen(buf), 0);
		memset(buf, 0, 0xFF);
		ssize_t len=recv(sockCli, buf, 0xFF, 0);
		printf("服务器:%d|%s\n", len,buf);
	}

	close(sockCli);
}

int main(int argc,char *argv[])
{
	if (argc == 1) {
		printf("请输入参数!");
		return -1;
	}

	if (strcmp("s", argv[1]) == 0) {
		printf("服务器开启!\n");
		Server();
	}
	else if(strcmp("c", argv[1]) == 0) {
		printf("客户端开启!\n");
		Client();
	}
	else {
		printf("无效参数!\n");
	}

}

通过main函数传入的参数,来判断使用哪一个函数,这样就实现了一个程序既可以当服务器,又可以当客户端!

最后点击生成即可。

3.4 运行测试

注意,linux系统是可以同时开启多个终端的,比如我这里就开启了两个终端,都来到了对应的程序生成目录中。

我们先传入参数"s"开启服务器,再传入参数"c"在另一个终端开启客户端:

image-20240103184323355

此时就能完美运行,客户端发送的任何消息都会立马收到服务器的回应。

4.UDP编程

学会了TCP编程,UDP就会简单很多,因为它还省略很多步骤,只需要换一些参数即可。

这里直接上代码:

#include <cstdio>
#include<sys/socket.h>
#include<arpa/inet.h>
#include <unistd.h>
#include<string.h>
void Server() {
	int sockListen = socket(AF_INET, SOCK_DGRAM, 0);
	sockaddr_in addr;
	addr.sin_family = AF_INET;
	addr.sin_port = htons(5000);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1");
	int ret = bind(sockListen, (sockaddr*)&addr, sizeof(addr));
	if (ret == -1) {
		printf("绑定失败!");
		return;
	}

	sockaddr_in cliAddr;
	socklen_t aLen= sizeof(cliAddr);
	while (1) {
		char buf[0xFF]{};
		size_t len=recvfrom(sockListen, buf, 0xFF, 0,(sockaddr*)&cliAddr,&aLen);
		if (len <= 0) {
			continue;
		}
		sendto(sockListen, buf, len, 0, (sockaddr*)&cliAddr, aLen);
	}
	close(sockListen);
}

void Client() {
	int sockCli = socket(AF_INET, SOCK_DGRAM, 0);
	sockaddr_in addr{};
	addr.sin_family = AF_INET;
	addr.sin_port = htons(5000);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1");

	char buf[0xFF];
	socklen_t alen = sizeof(addr);
	while (1) {
		printf("请输入信息:");
		scanf("%s",buf);
		sendto(sockCli, buf, strlen(buf), 0, (sockaddr*)&addr, alen);
		memset(buf, 0, 0xFF);
		ssize_t len=recvfrom(sockCli, buf, 0xFF, 0, (sockaddr*)&addr, &alen);
		printf("服务器:%d|%s\n", len,buf);
	}

	close(sockCli);
}

int main(int argc,char *argv[])
{
	if (argc == 1) {
		printf("请输入参数!");
		return -1;
	}

	if (strcmp("s", argv[1]) == 0) {
		printf("服务器开启!\n");
		Server();
	}
	else if(strcmp("c", argv[1]) == 0) {
		printf("客户端开启!\n");
		Client();
	}
	else {
		printf("无效参数!\n");
	}

}

上面的代码完全就是将上面的TCP代码改了改,删减不少,然后将socket函数的参数换为SOCK_DGRAM即可。

而收发函数,也还是在windows UDP编程中使用的recvfromsendto,含义都是一样的。

运行测试:

image-20240103184646849

5.高阶网络编程

如果继续只讲上面这点东西,也就没啥意思了,所以接下来我们就来了解一下更加高阶的网络编程技巧。

注意,下方讲解的网络编程技巧,windows系统同样存在,只是可能函数名字不同而已,但思路差不多。

但由于目前服务器基本都采用Linux系统进行开发,所以学windows网络编程太深,其实作用不大,纯纯有点浪费时间的感觉,当然如果你就是想要多了解一点东西,学一学那也是不错的。

5.1 引言

既然要了解高级网络编程技巧,那我们就得知道为什么需要它?如果没必要的话,我们又何必学它呢?

首先来考虑一件事情,比如我们上面的网络编程都是采用单进程以及单线程实现的。

注意Linux系统中的进程,使用起来与windows有点不同。

它可以非常简单的创建一个子进程,并共享父进程的东西,比如网络套接字。

Windows系统也是可以实现的,但会复杂很多。

所以在Windows系统中我们大多数首先会考虑多线程编程,因为其使用起来更加简单,但在Linux系统中,由于创建进程的过程实在是太过于简单,所以很多时候就会首先考虑使用多进程。

那么这个时候,如果有十个客户端连接上来呢?

此时无论是使用十个只有单线程的进程,还是使用有十个线程的单个进程,都是没有问题的。

但如果一万个,十万个,百万个客户端呢?

这是完全有可能的,QQ、微信、王者荣耀等等熟知的应用,每天的用户量都是非常大的。

但你也要知道,无论是创建进程,还是创建线程,都会消耗内存等资源,对进程和线程不了解的可以看看这篇文章:进程与线程

一个基本常识是,线程是电脑基本的执行单位,所以就算你使用64核的cpu,同一时间也只能处理64个线程。

而线程数量一旦比这个数量多了,就会频繁在cpu中更换线程,这个更换的过程非常浪费时间,会造成极大的cpu资源浪费。

所以一旦用户量一大,使用多线程,多进程方式,都不好。

注意这里的前提条件是用户量很大的情况,比如数千过万的情况就算比较大的了,百万用户量那种,基本已经不是单靠优化代码就能解决问题的了,那需要购买更多的服务器。

在这种情况下,为了提高程序的效率,才有了下面的高阶网络编程,不过我还是要讲讲最基本的多线程与多进程编程的情况。

5.2 多进程

首先我们来看多进程,多进程使用的函数为fork,它无需任何参数。

注意fork函数的逻辑,与windows的多进程存在很大区别

当前进程的线程一旦执行了这个函数,该进程就会生成一个子进程,并从这个函数返回。

也就是说,进去一个进程,出来的时候就是两个进程了!然后通过判断这个函数的返回值来确定当前是父进程还是子进程。

在父进程中,该函数返回子进程的进程ID,而在子进程中,该函数的返回值为0,如果失败,则返回-1

以上面的TCP程序为例子,改一改服务器的代码如下:

void Server() {
	int sockListen = socket(AF_INET, SOCK_STREAM, 0);