> 技术文档 > Linux网络编程:TCP初体验

Linux网络编程:TCP初体验

目录

前言:

服务端的准备

获取套接字

bind绑定

设置监听模式 

Start逻辑

Accept获取连接

进程使用连接后的文件描述符

客户端的架构

进程阻塞的问题


前言:

大家好啊,通过前几天的学习与实践,我们应该对UDP套接字的使用与特点有了初步的认识与了解了。

那么今天我们就开始另外一个套接字,TCP的使用吧!

值得一提的是,二者的使用其实有很大的相似程度,我们本篇文章主要还是通过创建一个简单的TcpServer为主。由于跟之前重合很多,所以有些思路,代码(重合的部分)我可能就不会具体在文章中写明,但是基本的注释还是会写上的。大家可以尝试借助代码注释来理解。

服务端的准备

首先,还是老规矩,创建服务端与客户端的头文件与源文件。

我们先开始对我们的服务端进行架构。

首先跟之前一样,我们需要一个服务端类,并创建相应的接口函数比如初始化函数,比如Start函数。

因为服务端启动了之后基本上是不会退出的,这也是通过在Start中添加一个while循环来实现的。

#pragma once#include #include static const uint16_t defaultport = 8080;class TcpServer{public: TcpServer() { } ~TcpServer() { } void InitServer() { } void Start() { }private: int sockfd; // 根据之前所学的UDP,我们可以知道,创建套接字其实就相当于打开了一个文件描述符。 // 这个文件描述符时全双工的特性,也就是说,既可以读也可以写,所以我们这里就事先弄一个成员变量用来管理这个文件描述符 bool is_running;//服务端的运行状态 uint16_t _port;};
#include\"TcpServer.hpp\"#includeint main(){ std::unique_ptr tcp_ptr=std::make_unique(); tcp_ptr->InitServer(); tcp_ptr->Start(); return 0;}

可以看见我们这里是没有定义一个默认的IP地址的。因为服务器最好还是使用我们之前所说的那个0,以便监控所有的IP信息。

到目前为止都是跟使用UDP一样的。

我们再来完善一下我们的构造函数:

 TcpServer(uint16_t port=defaultport ) :_port(port), is_running(false) { }

这样一来初步的准备工作就大功告成了。那我们剩下的重心主要就是放在我们的Init与Start的完善了。


那么我们的TCP他所做的初始化工作又是什么呢?

获取套接字

自然也是要先获取套接字也就是文件描述符信息。所用到的调用接口是一样的,也是socket,只是在参数上可能会有所改变:

第一个参数是协议族,指定套接字使用的网络协议栈类型。我们这里不做改变仍然是IPv4通信,所以还是使用AF_INET。

第二个参数套接字的类型,还记得我们的TCP的特点吗,他是可靠字节流,所以我们就要选择:SOCK_STREAM

第三个具体协议我们仍然不做改变填0就行,系统会为我们自己选择。

 所以我们获取套接字的部分其实就相当于改变了一下参数:

 _sockfd=::socket(AF_INET,SOCK_STREAM,0); if(_sockfd<0) { LOG(LogLevel::FATAL)<<\"_socket create error\"; Die(1); } LOG(LogLevel::DEBUG)<<\"_socket create success, sockfd if :\"<<_sockfd; }

那么接下来我们要干什么呢?


bind绑定

自然就是绑定了,就是让我们的套接字开辟的文件描述符与本地的IP端口这些信息做出绑定。

也就是说我们一样要自己的定义一个struct sockaddr_in 结构体,并在绑定时进行强制类型转化。

之前我们进行强制类型转化时都是直接使用括号,这次我们定义一个宏更显优雅一点:

#define CONV(v) (struct sockaddr *)(v)
 //接下来就是老规矩:bind //在此之前,我们需要先创建一个struct sockaddr_in结构体并给他们赋予初始值 struct sockaddr_in local; bzero(&local,sizeof(local)); local.sin_family=AF_INET; local.sin_port=htons(defaultport);//进行本地到网络的转化 local.sin_addr.s_addr=INADDR_ANY;//使用这个宏绑定套接字到本机的所有可用网络接口(即监听所有 IP 地址) int n=::bind(_sockfd,CONV(&local),sizeof(local));//绑定 if(n<0) { LOG(LogLevel::FATAL)<<\"bind error\"; Die(2); } LOG(LogLevel::DEBUG)<<\"bind success\";

这一步其实也是一模一样的。但是我们都知道,TCP是一种面向连接 的协议,这意味着在数据传输之前,通信双方必须 先建立逻辑连接,并在传输结束后 释放连接。

在 TCP 协议中,\"连接\"(Connection)是一个 逻辑上的通信通道,用于在客户端和服务器之间建立 可靠的、有序的 数据传输机制。它的核心作用是 让通信双方明确彼此的 identity(身份)和状态,确保数据能准确送达。

设置监听模式 

所以我们的初始化工作,这里就要新增一个处理:建立连接。 

那么我们如何建立连接呢?我们的服务端所需要做的工作就是监听!!

这里就要使用一个系统调用listen:

 #include  int listen(int sockfd, int backlog);

他的参数是什么意思呢?
首先第一个参数,根据名字我们可以知道,是一个文件描述符。我们要传进去的就是之前bind的文件描述符,注意,一定要是:

  • 通过 socket(AF_INET, SOCK_STREAM, 0) 创建的 流式套接字(TCP)。

  • 必须已调用 bind() 绑定到某个 IP 和端口。

 第二个参数又是什么意思呢?

首先想要与我们服务端建立请求的客户端可能会有多个。如果我们没有对其的请求进行处理,就会进入等待队列。而第二个参数的值,就是我们这个等待队列的最大数量。

一般来说,我们就填5就行。

 //TCP是一种面向连接的协议,所以我们需要建立连接 //设置为监听状态 n=::listen(_sockfd,5); if(n<0) { LOG(LogLevel::FATAL)<<\"listen error\"; Die(3); } LOG(LogLevel::INFO) << \"listen success, sockfd is : \" << _sockfd;

所以我们的初始化代码为:
 

 void InitServer() { _sockfd = ::socket(AF_INET, SOCK_STREAM, 0); if (_sockfd < 0) { LOG(LogLevel::FATAL) << \"_socket create error\"; Die(1); } LOG(LogLevel::INFO) << \"_socket create success, sockfd if :\" << _sockfd; //接下来就是老规矩:bind //在此之前,我们需要先创建一个struct sockaddr_in结构体并给他们赋予初始值 struct sockaddr_in local; bzero(&local,sizeof(local)); local.sin_family=AF_INET; local.sin_port=htons(defaultport);//进行本地到网络的转化 local.sin_addr.s_addr=INADDR_ANY;//使用这个宏绑定套接字到本机的所有可用网络接口(即监听所有 IP 地址) int n=::bind(_sockfd,CONV(&local),sizeof(local));//绑定 if(n<0) { LOG(LogLevel::FATAL)<<\"bind error\"; Die(2); } LOG(LogLevel::INFO)<<\"bind success\"; //TCP是一种面向连接的协议,所以我们需要建立连接 //设置为监听状态 n=::listen(_sockfd,5); if(n<0) { LOG(LogLevel::FATAL)<<\"listen error\"; Die(3); } LOG(LogLevel::INFO) << \"listen success, sockfd is : \" << _sockfd; }

Start逻辑

接下来就是我们的Start的使用了。

在开始的时候,我们就可以将运行状态设置为true表示正在运行了。

 void Start() { is_running=true; while(is_running) {  } }

但是这里开始我们不能像UDP一样直接获取数据的,而是必须先建立新链接。

Accept获取连接

所以我们就要用到accept函数:

 #include  int accept(int sockfd, struct sockaddr *_Nullable restrict addr,  socklen_t *_Nullable restrict addrlen);

让我来为大家解释一下这个函数的各个接口含义以及返回值:

首先第一个参数显而易见也是一个文件描述符:监听套接字的文件描述符(即 listen() 设置过的套接字)。

第二个参数,用于存储 客户端的地址信息(IP + 端口),所以他是一个输出型参数。

第三个参数,指定 addr 缓冲区的长度(如 sizeof(struct sockaddr_in)),在返回时返回实际写入的地址结构长度。如果 addr 是 NULL,此参数也应为 NULL

但是accept的返回值我们就要好好琢磨了。

他的返回值是什么呢?

也不瞒着大家,这个返回值,其实也是一个文件描述符。

有人就要问了,这么多文件描述符,那我传递消息用的是哪一个呢?

让我来为大家举一个例子:

就像大家在日常生活中,下了车站,遇见了一个拉客的人一样。

他会把客人拉到自己工作的酒店之类的。但是到了之后呢?他会说:那个谁谁谁xxx,你过来负责一下这些客人,于是从酒店就出来一个服务员来迎接。这个时候拉客的人呢?

他不会负责迎接,而是继续跑到车站拉其他客人。

我们之前创建的文件描述符与现在的返回值文件描述符的关系也是这样的。

之前创建的文件描述符就相当于在拉客,每一个返回值文件描述符的产生就是他拉到客人了,随后叫出来迎接的对应的一个服务员。

所以,我们之前的文件描述符叫做sockfd其实并不准确,准确的来说,我们应该给他改名为listensockfd,表示一个拉客的文件描述符。

int _listensockfd; // 根据之前所学的UDP,我们可以知道,创建套接字其实就相当于打开了一个文件描述符。 // 这个文件描述符时全双工的特性,也就是说,既可以读也可以写,所以我们这里就事先弄一个成员变量用来管理这个文件描述符

我们就这样写我们的接受连接代码:

 void Start() { is_running = true; while (is_running) { // 我们不能直接获取信息,但是要先建立连接: // 1、建立连接:accept struct sockaddr_in(peer); socklen_t peerlen = sizeof(peer); LOG(LogLevel::DEBUG) << \"accept ing ...\"; int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen); } }

 指的注意的是,当我们写一个判断sockfd是否小于0失败的日志输出时,日志类型应该是警告而不是致命。

如果没有拉到客人,就是走到一半失败了,他也会继续去车站拉客,不会出现什么阻塞,致命的情况。所以我们的处理通过是让他继续去车站拉客,这里使用continue而不是Die。

注意我们这里修改了类成员变量的名字,所以在前面的Init函数中所有有关变量都要一起修改。

 void Start() { is_running = true; while (is_running) { // 我们不能直接获取信息,但是要先建立连接: // 1、建立连接:accept struct sockaddr_in(peer); socklen_t peerlen = sizeof(peer); LOG(LogLevel::DEBUG) << \"accept ing ...\"; int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen); if(sockfd<0) { LOG(LogLevel::WARN)<< \"accept error: \" << strerror(errno); continue; } // 获取连接成功了 LOG(LogLevel::INFO) << \"accept success, sockfd is : \" << sockfd; }

大部分软件底层都是tcp,包括浏览器。这里我们可以做一个小实验,来帮助大家认识:

我们编译运行我们的服务端口:

可以看见,此时我们的拉客的文件描述符已经创建出来了,是3,此时服务端正在等候外来的连接。此时如果我们把我们云服务器的IP加端口输入到浏览器里会发生什么呢? 

我们连续进去三次,再来看我们的服务端:

 

可以看见我们的服务端接收到了这个连接请求,所以才会有这些打印消息的出现。


多进程使用连接后的文件描述符

那我们此时获取到了新的文件描述符,这个文件描述符也具有全双工的特性,我们该怎么使用呢?

一般会搭配多线程或者多进程,进程池这些使用。我们这里可以先试一下多进程:

 //v1多进程版本 pid_t id = fork(); if(id==0) { //此时子进程会进来,父进程会在外部 }

这里会引入一个新的问题,我们之前讲文件与进程时有没有说过?子进程会拷贝父进程的一张表。

所以一个文件描述符,可能会被多个进程使用,于是我们为了知道是否该关闭一个文件描述符,我内部使用了引用计数的方式。

子进程 不需要 监听套接字(_listensockfd),但会继承它。所以我们这里就需要让子进程关闭一下监听套接字:

 if (id == 0) { // 此时子进程会进来,父进程会在外部 // 子进程灰继承父进程的文件描述符表,所以需要关闭它,因为子进程不需要这个,防止因为引用计数的增加而导致出错 ::close(_listensockfd); HandlerRequest(sockfd); exit(0);//子进程无论如何都是要退出的,不能执行后面的代码 } int rid = ::waitpid(id, nullptr, 0);//父进程要进行等待

这个我们新增了一个函数调用,但是还没实现,这个就是我们服务端要做的接收子进程发来的消息的调用接口。

我们现在可以实现一下,因为我们一定是会通过sockfd文件描述符来获取消息,所以我们一定要把这个传过去,这不是类成员变量。

那么这个函数方法应该怎么实现呢?

需要明确的是,我们收到了来自客户端的连接请求,那么由于我们不知道他的目的到底是什么,所以,在发送指定的我们约定好的结束信号之前,服务端是不能主动不跟别人通话的。

这就代表着,我们需要应该while循环来解决这个问题。这也就是为什么我们要使用多线程,多进程的原因。

在读取数据的时候,我们可以使用read系统调用。这个接口之前在文件操作时我们是讲过使用的。随后我们如果读取成功,就把读取的消息在wirte回去。

 void Start() { is_running = true; while (is_running) { // 我们不能直接获取信息,但是要先建立连接: // 1、建立连接:accept struct sockaddr_in(peer); socklen_t peerlen = sizeof(peer); LOG(LogLevel::DEBUG) << \"accept ing ...\"; int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen); if (sockfd < 0) { LOG(LogLevel::WARN) << \"accept error: \" << strerror(errno); continue; } // 获取连接成功了 LOG(LogLevel::INFO) << \"accept success, sockfd is : \" << sockfd; // v1多进程版本 pid_t id = fork(); if (id == 0) { // 此时子进程会进来,父进程会在外部 // 子进程灰继承父进程的文件描述符表,所以需要关闭它,因为子进程不需要这个,防止因为引用计数的增加而导致出错 ::close(_listensockfd); HandlerRequest(sockfd); exit(0); // 子进程无论如何都是要退出的,不能执行后面的代码 } int rid = ::waitpid(id, nullptr, 0); // 父进程要进行等待 } } void HandlerRequest(int sockfd) { LOG(LogLevel::INFO) << \"HandlerRequest, sockfd is : \" < 0) { buffer[n] = 0; // 手动置入一个结束标记 std::string echo_str = \"server echo$\"; echo_str += buffer; write(sockfd, echo_str.c_str(), echo_str.size()); } else if (n == 0) { // n=0的情况我们说过,就是指的是服务端那里的write挂掉了,也就是说服务端退出了,那么我们此时约定,服务端也不再继续循环去读客户端的消息 LOG(LogLevel::INFO) << \"client quit: \" << sockfd; break; } else { break;//表示读取失败 } } }

大家请看,我们使用read与wirte,是不是就与之前我们的文件操作十分的相似了啊!!

这就是为什么我们要先学这些知识点的原因,不理解这些?我们如何理解网络?

至此,我们的服务端就可以进行一个基础的任务了。

但是,仍然存在许多安全隐患,这些我会后面提到。

我们先写一个客户端,来测试一下代码再说。


客户端的架构

对于客户端来说,他的代码也是极其相似的,我们直接复制粘贴了。但是会进行整改。

我们的获取套接字是仍然不变的。但是后面就有些变化了。

我们仍然不需要bind绑定,但是我们需要进行一个新的操作:connect连接,这个就与服务端的Accept是一套的,一个建立连接一个获取连接请求。

 #include int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

第一个参数就是我们的客户端刚刚创建的套接字,必须通过 socket(AF_INET, SOCK_STREAM, 0) 创建的 TCP 套接字。

第二个第三个参数相信大家都很熟悉了,所以我就不再赘述,是我们的服务端的地址信息。因为服务端IP与端口我们有个缺省值,在初始化时是用的缺省值。

我们虽然不仅学bind,但是仍然需要创建一个sockaddr_in的对象并对他进行初始化。

 // 创建一个struct sockaddr_in结构体并给他们赋予初始值 struct sockaddr_in local; bzero(&local, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(defaultport); // 进行本地到网络的转化 local.sin_addr.s_addr = inet_addr(defaultip.c_str()); // 把点分十进制的IP地址进行转化

在这之后,就要进行我们的connect操作,这个操作的核心意义在于 建立与目标服务器的网络连接,为后续数据传输奠定基础,防止出现丢包、乱序、重复这种情况。

 //与UDP一样,客户端不需要进行手动绑定 // TCP是一种面向连接的协议,所以我们需要建立连接 // 客户端进行连接操作 int n = ::connect(_sockfd, CONV(&local),sizeof(local)); if (n < 0) { LOG(LogLevel::FATAL) << \"connect error\"; Die(4); } LOG(LogLevel::INFO) << \"connect success, sockfd is : \" << _sockfd;

至此,我们客户端的初始化就已经完成了。

我们再来完成一下客户端的start,还是老规矩,所以不再强调。

但这里我们是通过文件读写的调用接口readyuwrite来完成消息的发送:
 

 void Start() { is_running = true; std::string message; while (is_running) { //设置一个缓冲区方便我们写入,读取数据 char buffer[1024]; std::cout< 0) { //写入成功就要开始读了 int m =::read(_sockfd,buffer,sizeof(buffer)); //如果读取返回的消息也成功了 if(m>0) {  buffer[m]=0;  std::cout<<buffer<<std::endl; } else {  break; } } else { break; } } }

总的代码如下:
 

#pragma once#include #include #include  // 核心Socket API(socket(), bind(), connect()等)#include  // IPv4/IPv6 地址结构(struct sockaddr_in)#include  // IP地址转换函数(inet_pton(), inet_ntop())#include  // 文件描述符操作(close(), read(), write())#include  //多进程需要#include \"log.hpp\"using namespace LogModule;static std::string defaultip = \"127.0.0.1\";//默认的IP1与端口static const uint16_t defaultport = 8080;#define Die(code) \\ do  \\ {  \\ exit(code); \\ } while (0)#define CONV(v) (struct sockaddr *)(v)class TcpClient{public: TcpClient(uint16_t port = defaultport) : _port(port), is_running(false) { } ~TcpClient() { } void InitClient() { _sockfd = ::socket(AF_INET, SOCK_STREAM, 0); if (_sockfd < 0) { LOG(LogLevel::FATAL) << \"_socket create error\"; Die(1); } LOG(LogLevel::INFO) << \"_socket create success, sockfd if :\" << _sockfd; // 创建一个struct sockaddr_in结构体并给他们赋予初始值 struct sockaddr_in local; bzero(&local, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(defaultport); // 进行本地到网络的转化 local.sin_addr.s_addr = inet_addr(defaultip.c_str()); // 把点分十进制的IP地址进行转化 //与UDP一样,客户端不需要进行手动绑定 // TCP是一种面向连接的协议,所以我们需要建立连接 // 客户端进行连接操作 int n = ::connect(_sockfd, CONV(&local),sizeof(local)); if (n < 0) { LOG(LogLevel::FATAL) << \"connect error\"; Die(4); } LOG(LogLevel::INFO) << \"connect success, sockfd is : \" << _sockfd; } void Start() { is_running = true; std::string message; while (is_running) { //设置一个缓冲区方便我们写入,读取数据 char buffer[1024]; std::cout< 0) { //写入成功就要开始读了 int m =::read(_sockfd,buffer,sizeof(buffer)); //如果读取返回的消息也成功了 if(m>0) {  buffer[m]=0;  std::cout<<buffer<<std::endl; } else {  break; } } else { break; } } }private: int _sockfd; // 根据之前所学的UDP,我们可以知道,创建套接字其实就相当于打开了一个文件描述符。 // 这个文件描述符时全双工的特性,也就是说,既可以读也可以写,所以我们这里就事先弄一个成员变量用来管理这个文件描述符 bool is_running; // 服务端的运行状态 uint16_t _port;};
#include\"TcpClient.hpp\"#includeint main(){ std::unique_ptr tcp_ptr=std::make_unique(); tcp_ptr->InitClient(); tcp_ptr->Start(); return 0;}

进程阻塞的问题

但是此时我们的服务端其实还有一个大麻烦。

就是我们创建一个子进程,并让父进程等待:

难道这样不会导致服务端阻塞住不能运行吗?

肯定会的,也就是说,我们要想办法解决父进程阻塞等待的情况。不阻塞等待肯定不行,这个需要循环配合。

那有没有什么情况,能让父进程是阻塞等待的情况下,让父进程继续运行呢?

有的,在讲信号的时候我们曾经提到过一个信号:SIGCHL 

当子进程退出(正常或异常)时,内核会向父进程发送 SIGCHLD 信号。父进程可以捕获该信号并调用 waitpid() 或 wait() 回收子进程资源(防止僵尸进程)。

所以我们可以对这个信号进行自定义处理行为: ::signal(SIGCHLD, SIG_IGN);

也就是忽略 SIGCHLD 信号,当子进程退出时,父进程不会收到 SIGCHLD 信号,内核会自动回收子进程资源,不会产生僵尸进程。

 void InitServer() { ...... // TCP是一种面向连接的协议,所以我们需要建立连接 // 设置为监听状态 n = ::listen(_listensockfd, 5); if (n < 0) { LOG(LogLevel::FATAL) << \"listen error\"; Die(3); } LOG(LogLevel::INFO) << \"listen success, sockfd is : \" << _listensockfd; ::signal(SIGCHLD, SIG_IGN);//忽略子进程发给父进程的信号,让子进程结束时直接被内核回收 }

此时我们就可以不再手动使用waitpid了。

并且我们还有一个方法,我们只需要在子进程执行回调之前,让子进程再创建一个孙子进程,随后子进程结束自己,我们就能让孙子进程执行回调函数,并且孙子进程会被托管给pid为1的这些进程去等待:

 void Start() { is_running = true; while (is_running) { // 我们不能直接获取信息,但是要先建立连接: // 1、建立连接:accept struct sockaddr_in(peer); socklen_t peerlen = sizeof(peer); LOG(LogLevel::DEBUG) << \"accept ing ...\"; int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen); if (sockfd < 0) { LOG(LogLevel::WARN) << \"accept error: \" << strerror(errno); continue; } // 获取连接成功了 LOG(LogLevel::INFO) << \"accept success, sockfd is : \" < 孤儿进程 -> 1  // 此时孙子进程会被我们的system,或者init这种pid为1的进程接管,由他们负责等待 } HandlerRequest(sockfd); exit(0); // 子进程无论如何都是要退出的,不能执行后面的代码 } // int rid = ::waitpid(id, nullptr, 0); // 父进程要进行等待 }

这样我们的echoserver就能够运行起来了!!