套接字socket编程的基础知识点

目录

前言(必读)

网络字节序

网络中的大小端问题

为什么网络字节序采用的是大端而不是小端?

网络字节序与主机字节序相互转换的方式

字符串IP和整数IP

整数IP存在的意义

字符串IP和整数IP相互转换的方式 

inet_addr函数(会自动将转化出的整数IP从主机字节序变为网络字节序)

inet_ntoa函数(会自动先把从网络中读取到的整数IP从网络字节序转化成主机字节序)

sockaddr、sockaddr_in、sockaddr_un结构体

对sockaddr_in的补充说明

socket编程的常见函数

在基于UDP协议的socket编程中和在基于TCP协议的socket编程中通用的函数

socket函数

bind函数(以及需要进行bind的原因)

close函数

基于UDP协议的socket编程的常见函数

sendto函数

recvfrom函数

不要混淆socket套接字和文件的概念

基于TCP协议的socket编程的常见函数

setsockopt函数

listen函数

accept函数

connect函数

read函数

write函数

recv函数

send函数

本地环回地址和INADDR_ANY地址

为什么云服务器上的进程在bind绑定INADDR_ANY后,其他主机就可以通过云服务器的虚拟的ip地址访问该进程了呢?

云服务器上的进程bind绑定云服务器的公网IP失败的问题

面向字节流和面向数据报(包含发送缓冲区和接收缓冲区的知识点)

守护进程(包含终端、bash、前后台进程、进程组、会话的概念)

将一个进程变成守护进程的完整流程(代码)

如何证明已经将一个进程变成了守护进程呢?

如何将一个守护进程关闭呢?


前言(必读)

注意本文中说明的是套接字socket编程的基础知识点,关于这些知识点的更深入的使用方式和场景,还是得在笔者关于【基于UDP协议的网络服务器的模拟实现】和【基于TCP协议的网络服务器的模拟实现】的文章中才能体现出来

网络字节序

网络中的大小端问题

计算机在存储数据时是有大小端的概念的:

  • 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
  • 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。

如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。如下图,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的。

但由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为44332211的序列,发送端按小端的方式识别出来是0x11223344,而接收端按大端的方式识别出来是0x44332211,此时接收端识别到的数据与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误。

由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。

  • 如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
  • 如果发送端是大端,则可以直接进行发送。
  • 如果接收端是小端,需要先将接收到数据转成小端后再进行数据识别。
  • 如果接收端是大端,则可以直接进行数据识别。

在这个例子中,由于发送端是小端机,因此在发送数据前需要先将数据转成大端,然后再发送到网络当中,而由于接收端是大端机,因此接收端接收到数据后可以直接进行数据识别,此时接收端识别出来的数据就与发送端原本想要发送的数据相同了。

需要注意的是,所有的大小端的转化工作是由操作系统来完成的,因为该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。

为什么网络字节序采用的是大端而不是小端?

问题:网络字节序采用的是大端,而主机字节序一般采用的是小端,那为什么网络字节序不采用小端呢,毕竟如果网络字节序采用小端的话,发送端和接收端在发生和接收数据时就不用进行大小端的转换了。

答案:该问题有很多不同说法,下面列举了两种说法:

说法一: TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了。

说法二: 大端序更符合现代人的读写习惯。

网络字节序与主机字节序相互转换的方式

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了四个函数,可以通过调用以下库函数实现网络字节序和主机字节序之间的转换:

(头文件都是#include <arpa/inet.h>)

  • uint32_t htonl(uint32_t hostlong);
  • uint16_t htons(uint16_t hostshort);
  • uint32_t ntohl(uint32_t netlong);
  • uint16_t ntohs(uint16_t netshort);

函数名当中的h表示host,n表示network,l表示32位长整数,s表示16位短整数。例如htonl表示将32位长整数从主机字节序转换为网络字节序。如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回。如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回。

说一下,因为【字符IP先转化成整形IP、整形IP再从主机字节序转化成网络字节序】靠inet_addr函数即可,【整形IP先从网络字节序转化成主机字节序,然后主机字节序的整形IP再从整形IP转化成字符IP】靠inet_ntoa函数即可,所以上面所讲的网络字节序与主机字节序相互转换的方式主要是用于将整形的port端口号在网络字节序与主机字节序之间相互转换。

字符串IP和整数IP

IP地址的表现形式有两种:

  • 字符串IP:类似于192.168.233.123这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址。
  • 整数IP:IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址。

整数IP存在的意义

网络传输数据时是寸土寸金的,如果我们在网络传输时直接以基于字符串的点分十进制IP的形式进行IP地址的传送,那么此时一个IP地址至少就需要15个字节,但实际并不需要耗费这么多字节。

IP地址实际可以划分为四个区域,其中每一个区域的取值都是0~255,而这个范围的数字只需要用8个比特位就能表示,因此我们实际只需要32个比特位就能够表示一个IP地址。其中这个32位的整数的每一个字节对应的就是IP地址中的某个区域,我们将IP地址的这种表示方法称之为整数IP,此时表示一个IP地址只需要4个字节。

因为采用整数IP的方案表示一个IP地址只需要4个字节,并且在网络通信也能表示同样的含义,因此在网络通信时就没有用字符串IP而用的是整数IP,因为这样能够减少网络通信时数据的传送。

字符串IP和整数IP相互转换的方式 

inet_addr函数(会自动将转化出的整数IP从主机字节序变为网络字节序)

实际在进行字符串IP和整数IP的转换时,我们不需要自己编写转换逻辑,系统已经为我们提供了相应的转换函数,我们直接调用即可。

函数用于【先将字符串IP转化成整数IP,然后把整数IP从主机字节序转化成网络字节序】,该函数的函数原型如下:

in_addr_t inet_addr(const char *cp);

该函数使用起来非常简单,我们只需传入待转换的字符串IP,该函数返回的就是转换后的整数IP。除此之外,inet_aton函数也可以将字符串IP转换成整数IP,不过该函数使用起来没有inet_addr简单。再次强调,inet_addr会做两个操作,1、将点分十进制字符串变为整数后,2、还会将整数从主机字节序变为网络字节序。

inet_ntoa函数(会自动先把从网络中读取到的整数IP从网络字节序转化成主机字节序)

函数用于【先将整数IP从网络字节序转化成主机字节序,然后将主机字节序的整数IP转换成字符串IP】,转化成字符串ip后会将该字符ip存储在某块空间上,函数的返回值就是这块空间的地址,该函数的函数原型如下:

char *inet_ntoa(struct in_addr in);

需要注意的是,传入inet_ntoa函数的参数类型是in_addr,in_addr就是sockaddr_in结构体的成员之一,因此我们在传参时不需要选中in_addr结构当中的32位的成员传入,直接传入in_addr结构体即可。

sockaddr、sockaddr_in、sockaddr_un结构体

套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中sockaddr_in结构体是用于跨网络通信的,而sockaddr_un结构体是用于本地通信的。

为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。

此时当我们在调用sendto、recvfrom或者其他函数需要传参时,就不用传入sockeaddr_in或sockeaddr_un这样的结构体,而统一传入sockeaddr这样的结构体。我们在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API(即sendto、recvfrom或者其他函数)内部就可以提取sockeaddr结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。此时我们就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一。(注意实际我们在进行网络通信时,定义的还是sockaddr_in这样的结构体,只不过在调用sendto、recvfrom或者其他函数时,在传参时需要将该结构体的地址类型进行强转为sockaddr*)

问题:读了上一段我们可能会有一个疑问,即为什么没有用void*代替struct sockaddr*类型?我们可以将这些函数的struct sockaddr*参数类型改为void*,此时在函数内部也可以直接指定提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信,那为什么还要设计出sockaddr这样的结构呢?

答案:实际在设计这一套网络接口的时候C语言还不支持void*,于是就设计出了sockaddr这样的解决方案。并且在C语言支持了void*之后也没有将它改回来,因为这些接口是系统接口,系统接口是所有上层软件接口的基石,系统接口是不能轻易更改的,否则引发的后果是不可想的,这也就是为什么现在依旧保留sockaddr结构的原因。

对sockaddr_in的补充说明

sockaddr_in结构体的定义如下图右半部分,可以看到struct sockaddr_in中的成员有:

  • sin_family:表示协议家族。对socket编程不太熟悉的同学一定要注意在实际编写代码的过程中不要把这个字段给忘了,如果不对这个字段进行初始化,则在调用send函数和recv函数时进程就会直接崩溃。
  • sin_port:表示端口号,是一个16位的整数。
  • sin_addr:表示IP地址,是一个32位的整数。

剩下的字段一般不做处理,当然你也可以进行初始化。其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员(如上图左半部分),该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的。

socket编程的常见函数

在基于UDP协议的socket编程中和在基于TCP协议的socket编程中通用的函数

socket函数

int socket(int domain, int type, int protocol);

参数说明:

  • domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于struct sockaddr结构的前16个位。如果是本地通信就设置为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。
  • type:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM和SOCK_DGRAM,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。
  • protocol:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。

返回值说明:

  • 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。

注意事项:

  • 如果调用socket失败,是需要立刻让进程退出的,这是因为socket失败时,后序进行任何网络操作的结果都是未知的,是错误的,所以如果进程不退出,继续向下执行代码就会出现各种错误。而想让进程在调用socket失败时退出,就需要程序员编码控制,比如设置if分支,socket失败就进入if分支调用exit函数使当前进程退出;而不能指望系统,因为调用socket函数失败时,系统并不会自动结束当前进程,而是会继续向下执行代码,所以想让进程在调用socket函数失败时退出,就需要程序员编码控制。

功能说明:

  • 说简单点就是创建了一个文件,并返回了一个指向该文件的文件描述符,之后我们就可以在当前进程中向这个文件里写入数据并向网络中发送,或者从网络中读取数据到这个文件里并再将数据从文件中读到当前进程里。

问题:socket为什么可以具备这样的功能呢?它的底层干了什么?

答案:(结合下图思考)socket函数是被进程所调用的,而每一个进程在系统层面上都有一个进程地址空间PCB(task_struct)、文件描述符表(files_struct)以及对应打开的各种文件。而文件描述符表里面包含了一个数组fd_array成员,其中数组中的0、1、2下标依次对应的就是标准输入、标准输出以及标准错误。

(结合下图思考)当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。

其中每一个struct file结构体中包含的就是对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由struct inode结构体来维护的;而文件对应的操作方法实际就是一堆的函数指针(比如read*和write*),在内核当中就是由struct file_operations结构体来维护的。

而对于文件缓冲区,OS会为不同的文件都分配一块内存,用于暂时在内存中保存属于文件的数据:

  • 比如在当前情景下,网络文件的文件缓冲区就是OS为网络文件分配的一块内存,用于在内存中暂时保存属于该网络(对应某台主机上的某个进程)的数据,之后会根据属于网络文件的文件缓冲区的刷新策略,将文件缓冲区中的数据刷新到OS为网卡文件分配的一块内存(即网卡文件的缓冲区)上,网卡缓冲区再根据自己的刷新策略将数据刷新到内核,再由内核刷新到网卡设备上,网卡就可以根据自己的刷新策略向网络中发送信息了。
  • 再比如普通磁盘文件的文件缓冲区就是OS为磁盘文件分配的一块内存,用于在内存中暂时保存属于磁盘设备(或者说磁盘文件)的数据,之后会根据属于磁盘文件的文件缓冲区的刷新策略,将文件缓冲区中的数据刷新到内核,再由内核刷新到磁盘设备(或者说磁盘文件)上,这就完成了一次写入磁盘的操作。

bind函数(以及需要进行bind的原因)

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

调用完socket函数成功创建了套接字文件后,需要调用bind函数将【当前进程】和【某个ip与某个port与某个socket文件】进行绑定,原因为:

  • (结合下图思考)现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不会知道是要将该文件的数据写入到磁盘还是刷到网卡,因为此时该文件还没有与网卡或者说网络关联起来。
  • 套接字socket文件用于通信,首先,如果想要网络通信,则必须通过网卡,如果是作为收信息的一方,则你必须得指定从哪个网卡(ip)读取数据送到socket文件,这就是bind ip的原因,读取的数据送到哪个socket文件呢?这就是bind sockfd的原因,数据读取到socket文件后,把socket文件中的数据送到哪个端口(进程)呢?所以你必须指定一个端口号,这是bind port的原因;如果是发信息的一方,则你必须得指定把哪个端口(port)对应的进程中的数据发送到socket文件中,这是bind port的原因,把进程中的数据发送到哪个socket文件中呢?这就是需要bind sockfd的原因,进程中的数据发到了socket文件中后,你得指定从哪个网卡(ip)将socket文件中的数据送到网络中,这是bind ip的原因。

参数说明:

  • int sockfd。发信息需要一个通信通道,这个通道为【当前进程--->内核缓冲区--->sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->网卡--->网络--->对方的网卡--->对方的sockfd指向的文件的文件缓冲区--->对方的内核缓冲区--->对方的进程】。接收信息需要一个通信通道,这个通道为【对方进程--->对方的内核缓冲区--->对方进程的sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->对方的网卡--->网络--->当前主机的网卡--->当前进程的sockfd指向的文件的文件缓冲区--->当前主机的内核缓冲区--->当前的进程】,可以看到在收或者发信息时,sockfd指向的文件是作为通信通道的一环的,所以调用bind函数需要sockfd就是在告诉bind函数,我需要将哪个文件设置成通信通道的一环。 
  • const struct sockaddr *addr。bind函数用于将【当前进程】和【某个ip与某个port】进行绑定,ip和port信息就包含在addr指向的sockaddr对象中。
  • socklen_t addrlen。为上一个参数addr指针指向的sockaddr对象的大小,传入sizeof(上一个参数addr指针指向的sockaddr对象)即可。

返回值说明:

  • 如果bind函数成功执行,它将返回 0。这表示套接字成功绑定到指定的地址和端口。

  • 如果bind函数执行失败,它将返回 -1。这表示绑定操作未成功,并且通常会伴随着设置全局变量errno来指示错误的原因。通过检查bind函数的返回值和检查errno变量的值,你可以确定bind失败的原因,以便进行适当的错误处理。一些常见的bind失败原因包括:1、端口已经被占用:如果指定的端口已经被其他程序占用,bind将失败,并且errno可能会被设置为 EADDRINUSE。2、无效的地址或端口:如果指定的地址或端口无效,bind也会失败,并且errno的值会指示具体的错误类型。3、权限不足:有些系统可能要求程序拥有特定的权限才能绑定到某些端口,如果权限不足,bind也会失败,并且errno的值可能会指示权限相关的错误。因此,当你调用bind函数时,应该检查其返回值,如果返回值是-1,则通过查看errno的值来确定失败的原因,并根据错误原因进行适当的错误处理。

(很重要,一定要看)补充说明:

  • 说一下,如果发现自己不明白什么是TIME_WAIT状态,请移步到<<传输层协议——TCP协议>>一文中标题为4次挥手和标题为关于TIME_WAIT状态的部分。接下来进入正题:如果启动当前进程时bind绑定某ip和port成功,但当前进程在退出后处于TIME_WAIT状态,那么重新启动当前进程时调用bind函数绑定和之前相同的ip和端口号就会失败(这一点在该篇文章中进行过证明),用于解决这个问题的setsockopt函数也在该篇文章中进行过说明。
  • 注意如果调用bind失败,是需要立刻让进程退出的,这是因为bind失败时,当前进程不会与【任何端口号和ip】进行绑定,套接字也是无效的,它不能用于接受连接或进行任何其他网络操作,所以如果进程不退出,继续向下执行就会出现各种错误。而想让进程在调用bind失败时退出,就需要程序员编码控制,比如设置if分支,bind失败就进入if分支调用exit函数使当前进程退出;而不能指望系统,因为调用bind函数失败时,系统并不会自动结束当前进程,而是会继续向下执行代码,所以想让进程在调用bind函数失败时退出,就需要程序员编码控制。

close函数

tips:如果不理解接下来要说的知识点,建议先阅读下文中标题为面向字节流和面向数据报(包含发送缓冲区和接收缓冲区的知识点)的部分的内容。

(注意当前这一段内容仅用于描述TCP通信,UDP通信是没有这一点的,因为UDP通信时双方都没有发送缓冲区而只有接收缓冲区)close函数不仅能够关闭掉套接字文件对应的文件描述符,还能关闭通信通道。举个例子,客户端调用close函数就能销毁从客户端到服务端方向的通信信道(即从客户端的发送缓冲区到服务端的接收缓冲区的信道),此时就不会再有任何数据会被OS根据TCP协议控制着从客户端的发送缓冲区流向到服务端的接收缓冲区;服务端调用close函数就能销毁从服务端到客户端的通信信道(即从服务端的发送缓冲区到客户端的接收缓冲区的信道),此时就不会再有任何数据会被OS根据TCP协议控制着从服务端的发送缓冲区流向到客户端的接收缓冲区。

基于UDP协议的socket编程的常见函数

sendto函数

补充知识点:

  • 在UDP通信中,当客户端使用sendto函数向服务端发送数据时,客户端的IP地址和端口号通常会包含在UDP数据包的源地址字段中一并发给服务端,以便服务端知道数据来自哪个客户端,这是UDP通信的基本原理之一。具体来说,客户端使用sendto函数指定目标服务端的IP地址和端口号,以及要发送的数据,服务端在通过recvfrom函数接收数据时,可以通过传给recvfrom函数的输出型参数从UDP数据包中读取客户端的IP地址和端口号,以确定数据的来源。

  • 但在UDP通信中,服务端在调用recvfrom函数接收客户端的信息时,并不会自动将自己的IP地址和端口号反馈给客户端。UDP是一种无连接协议,它不维护像TCP那样的连接状态,因此在每次数据包传输时,并不涉及类似TCP三次握手的连接建立过程。

参数说明:

  • int sockfd,sendto函数是向某台机器上的某个进程发信息,发信息需要一个通信通道,这个通道为【当前进程--->内核缓冲区--->sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->网卡--->网络--->对方的网卡--->对方的sockfd指向的文件的文件缓冲区--->对方的内核缓冲区--->对方的进程】,所以也就能够理解sockfd这个参数的作用了,即给sendto函数提供文件描述符,以找到其指向的文件的缓冲区,提供通信的媒介。
  • void *buf,sendto函数是向某台机器上的某个进程发信息,那么需要发出的信息是什么呢?buf指针指向的数据就是这个待发的信息。buf的类型是void*,方便sendto发送不同种类的信息。
  • size_t len,sendto函数是向某台机器上的某个进程发信息,len就用于指定发送多大长度的信息。注意这个len不一定是实际发送信息的长度,只是用户指定并期望发这么多,如果用户指定发送的长度远远大于了buf指针指向数据的长度,那实际只会发送buf指针指向数据的长度个数据。实际发送的数据的长度可以通过sendto的返回值获取。
  • int flags,设置为0即可,不必关心。
  • const struct sockaddr *dest_addr,sendto函数是向某台机器上的某个进程发信息,向哪台机器和哪个进程发送就是通过dest_addr指针(dest即destination,翻译为目的地)指向的sockaddr对象决定的,sockaddr对象里包含了标识目标主机的ip地址和标识目标进程的端口号port。
  • socklen_t addrlen就是上一个指针参数指向的sockaddr对象所占的空间大小,传入sizeof(sockaddr对象即可)。

返回值说明:

  • 在参数中已经隐含了该信息,即表示当前进程实际发送给对方进程的数据的长度。如果发生错误,返回值为-1。
  • 注意,和TCP不同,UDP的读端进程即使close关闭了sockfd,或者是直接进程退出了(假如服务端是读端),写端进程(客户端)继续调用sendto也不会收到SIGPIPE信号进而被OS杀死(但TCP是会收到该信号进而导致写端进程被杀死的,这也是为什么平时我们ctrl c掉服务端,所有客户端也退出了。说一下,即使是在TCP通信中,服务端被杀死,客户端也是不会自动退出的,如果客户端也退出了,则一定是因为我们的编码逻辑,比如是在没有把SIGPIPE信号的处理函数设置成忽略的情况下,在读端进程退出后,当前写端进程继续调用write函数,于是写端进程就收到了SIGPIPE信号,进而被终止了,也就是说如果写端进程这时没有调用write函数,它是不会被杀死的,或者是我们提前设置了SIGPIPE信号的信号处理函数,这时即使收到了该信号,写端进程也是不会退出的,写端进程也不会自动进程退出,并且写端(客户端)是可以继续给读端(服务端)发送数据的,只不过读端读取不到而已。为什么UDP的写端进程不会收到SIGPIPE信号进而导致写端进程退出呢?这是因为UDP是一个无连接的协议,它不维护连接状态,也不跟踪对端是否关闭。

recvfrom函数

补充知识点:

  • 在UDP通信中,当客户端使用sendto函数向服务端发送数据时,客户端的IP地址和端口号通常会包含在UDP数据包的源地址字段中一并发给服务端,以便服务端知道数据来自哪个客户端,这是UDP通信的基本原理之一。具体来说,客户端使用sendto函数指定目标服务端的IP地址和端口号,以及要发送的数据,服务端在通过recvfrom函数接收数据时,可以通过传给recvfrom函数的输出型参数从UDP数据包中读取客户端的IP地址和端口号,以确定数据的来源。

  • 但在UDP通信中,服务端在调用recvfrom函数接收客户端的信息时,并不会自动将自己的IP地址和端口号反馈给客户端。UDP是一种无连接协议,它不维护像TCP那样的连接状态,因此在每次数据包传输时,并不涉及类似TCP三次握手的连接建立过程。

参数说明:

  • int sockfd。recvfrom函数是用于接收某台机器上的某个进程发过来的信息,接收信息需要一个通信通道,这个通道为【对方进程--->对方的内核缓冲区--->对方进程的sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->对方的网卡--->网络--->当前主机的网卡--->当前进程的sockfd指向的文件的文件缓冲区--->当前主机的内核缓冲区--->当前的进程】,所以也就能够理解sockfd这个参数的作用了,即给recvfrom函数提供文件描述符,以找到其指向的文件的缓冲区,提供通信的媒介。
  • void *buf。recvfrom函数是用于接收某台机器上的某个进程发过来的信息,那么需要接收的信息需要存在哪里呢?buf指针指向的这块空间就用于存放这个接收到的信息。buf的类型是void*,方便recvfrom接收不同种类的信息。
  • size_t len。recvfrom函数是用于接收某台机器上的某个进程发过来的信息,len就用于指定接收多大长度的信息。注意这个len不一定是实际接收信息的长度,只是用户指定并期望接收这么多,如果用户指定接收的长度远远大于了buf指针指向空间所能容纳的最大长度,那实际只会接收buf指针指向空间的最大长度个数据。实际接收的数据的长度可以通过recvfrom的返回值获取。
  • int flags。设置为0即可,不必关心。
  • const struct sockaddr *src_addr。src即sourcere,翻译为来源。recvfrom函数是用于接收某台机器上的某个进程发过来的信息的,那是哪台机器上的哪个进程给我发的信息呢?我们可以通过src_addr指针指向的sockaddr对象得知。说一下,src_addr是个输出型参数,我们需要先设置一个sockaddr的对象,无所谓是否初始化它,然后把该sockaddr对象的地址传入recvfrom函数,函数调用结束后,src_addr指针指向的这个sockaddr对象中就包含了【是哪台机器的哪个进程给我发信息】的信息,即sockaddr对象里包含了标识【给我发信息的主机】的ip地址和标识【给我发信息的进程】的端口号port。
  • socklen_t *addrlen。其是个输入输出型参数,在调用recvfrom函数前,addrlen指针指向【表示上一个参数src_addr指向对象大小】的socklen_t对象,所以调用recvfrom函数时给addrlen传入一个值初始化成了sizeof(scr_addr指向的sockaddr对象)的socklen_t对象的地址即可;调用recvfrom函数结束后,addrlen指针指向【表示实际填充进上一个参数scr_addr指向的sockaddr对象的数据的大小】的socklen_t对象。既然addrlen是个输入输出型参数,那么使用它的方式为:调用recvfrom函数前,我们得先设置一个socklen_t的对象,然后将它初始化成sizeof(scr_addr指向的sockaddr对象),然后将该socklen_t对象的地址传给recvfrom函数的形参addrlen,recvfrom函数调用完毕后,addrlen指向的socklen_t对象的值就变成了实际填充进上一个参数scr_addr指向的sockaddr对象的数据的大小。

返回值说明:

  • 在参数中已经隐含了该信息,即表示实际接收到的对方进程发过来的数据的长度。如果发生错误,返回值为-1。
  • 注意,和TCP不同的是,UDP的写段进程如果close了sockfd,或者直接退出了写端进程,读端进程调用recvrom函数也不会返回0,而是继续阻塞(如果给套接字通过fcntl函数设置了非阻塞属性,则recvfrom返回-1,并且errno会被设置成EAGAIN或者说EWOULDBLOCK;如果给套接字设置了超时属性,则返回-1,并且errno会被设置成ETIMEDOUT,设置方式见下图),这是因为UDP是一个无连接的协议,它不维护连接状态,也不跟踪对端是否关闭。

不要混淆socket套接字和文件的概念

阅读到这里,我们已经阅读完了上面socket、sendto、recvfrom这3个函数,在上面的讲解中,我们把socket套接字称为socket套接字文件,即认为socket套接字是文件,如果从不严格的视角上看,的确是可以这么认为的;但如果从严格的视角上看,这是不完全对的,只能算半对。上文讲解这3个函数时我们把socket套接字称为socket套接字文件、即认为socket套接字是文件,只是为了方便读者理解函数的底层实现、方便让读者理解通信双方的数据是需要在套接字中传输的,并且数据在套接字中传输的方式类似于数据在文件中传输,套接字是作为通信信道的一环。

这里我们以严格的视角,再重新更深入的认识一下socket套接字和文件的区别。

正确的认识如下:

问题1:在上文中讲解socket函数时说过一句话,观点是这样的:“当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。” 但现在你告诉我说套接字不是文件,我该如何理解呢?

答案1如下:

  • 套接字不是文件: 套接字和文件是两种不同的概念。文件通常是磁盘上存储数据的持久性对象,而套接字是用于网络通信的虚拟通信端点。套接字在操作系统内部有自己的实现方式,用于处理网络数据的传输和接收,而不是像文件一样在磁盘上存储数据。

  • 文件描述符与套接字: 在UNIX和类UNIX系统中,文件描述符是一个整数,用于标识打开的文件、套接字和其他I/O资源。当你调用socket函数创建套接字时,操作系统会返回一个文件描述符,这个描述符用于标识该套接字。这个文件描述符与文件描述符数组(通常是fd_array)中的一个位置相关联,但并不意味着套接字就是一个文件。

  • struct file 结构体与文件描述符: 在UNIX-like系统中,操作系统维护一个文件表,其中包括一个 struct file 结构体数组。每个 struct file 结构体表示一个已打开的文件、套接字或其他I/O资源。文件描述符(例如,3号文件描述符)是一个索引,用于访问文件表中的 struct file 结构体。套接字和文件都可以在文件表中有对应的 struct file 结构体,但它们的实现和用途不同。

  • 总之,虽然套接字和文件都可以用文件描述符来标识和操作,但它们在底层实现和用途上是不同的。套接字是用于网络通信的抽象通信端点,而文件是用于数据存储和读写的持久性对象。因此,尽管它们都与文件描述符相关联,但不应将套接字等同于文件。

问题2:在进程中除了打开文件会创建struct file,还会有其他情况会创建struct file吗?

答案2:在进程中除了打开文件可能创建 struct file 结构外,还有其他情况可能会创建这种结构。 struct file 是用于表示已打开文件的数据结构,但在不同的操作系统和情况下,也可以用于表示其他类型的 I/O 资源,而不仅限于磁盘上的文件。以下是一些可能创建 struct file 结构的情况:

  • 打开文件: 当进程打开一个文件时(例如通过 open() 系统调用),操作系统会创建一个 struct file 结构来表示这个已打开的文件,以便后续对文件的读写等操作。

  • 创建套接字: 当进程调用 socket() 等套接字相关的系统调用创建套接字时,操作系统会创建一个 struct file 结构来表示这个套接字。这个结构可能在某些系统中称为套接字描述符,但与普通文件描述符一样,它们可以与 struct file 相关联。

  • 创建管道: 管道是一种特殊的文件,用于进程间通信。当进程调用 pipe() 创建管道时,可能会创建相应的 struct file 结构来表示管道。

  • 创建字符设备或块设备文件: 进程可能会打开字符设备或块设备文件,例如硬盘分区或串口设备。这些设备文件也可能涉及创建 struct file 结构。

  • 网络设备和虚拟文件系统(VFS): 在网络编程中,涉及网络设备时可能会创建 struct file 结构。同时,在虚拟文件系统中可能有自定义的文件类型,对应的创建也可能涉及创建 struct file

  • 总的来说,struct file 结构用于表示进程中打开的各种 I/O 资源,不仅限于普通文件。这些资源可以是磁盘文件、套接字、管道、设备文件等。每种资源类型都可能涉及创建相应的 struct file 结构来进行管理。

问题3:Linux中不是一切皆文件吗?现在你跟我说套接字不是文件?

答案3:在Linux和类UNIX操作系统中,"一切皆文件" 是一个广泛传播的概念,意味着大多数系统资源(包括设备、文件、套接字等)都可以通过文件描述符进行访问和操作。这个概念是UNIX哲学的一部分,它使得编程更加一致和灵活,因为它允许开发者使用类似的API来处理各种资源。

但注意从严格的视角上看,这个观点并不一定正确:虽然"一切皆文件"是一个重要的概念,但不是所有的资源都是严格的文件的,而是它们可以以类似文件的方式被进行访问和管理。套接字就是一个例子。套接字是用于网络通信的抽象通信端点,它们并不是磁盘上的文件,而是用于数据交换的网络连接。尽管套接字可以使用文件描述符进行标识和操作,但它们的底层实现与普通文件不同,因为它们是用于网络通信的。所以,可以说在Linux中,一切都可以通过文件描述符进行访问,但并不是所有的资源都是严格的文件。套接字是一种特殊的资源,它们以类似文件的方式进行访问,但在内核中的实现与普通文件不同。

但如果从不严格的视角上看,这个观点又是正确的:在Linux和类UNIX系统中,"一切皆文件" 是一个常见的概念,表示许多系统资源都可以通过文件描述符进行访问和操作,包括文件、设备和套接字等。套接字也是一种资源,它可以像文件一样通过文件描述符进行访问和操作。所以,从这个角度来看,套接字也可以被视为一种文件。综上所述,套接字确实符合"一切皆文件"的概念,因为它们可以像文件一样通过文件描述符进行操作。因此,你可以相信"一切皆文件"这个概念,包括套接字在内。

问题4:既然从严格视角下看,套接字并不是文件,那它到底是什么?底层又是怎么实现的呢?

到底是什么呢?

  • 套接字不是一个文件,它是操作系统提供的一种通信机制,用于在网络上进行数据传输。套接字与文件在本质上有很大的不同。
  • 文件通常是一个存储在磁盘上的持久化数据对象,可以按照顺序读取和写入。文件通常具有持久性,它们的内容可以长期保存在磁盘上,并且可以在不同的时间点被多个程序访问。
  • 套接字,与文件不同,是用于实时通信的虚拟端点。套接字允许两台计算机上的程序通过网络进行实时数据交换。套接字通常不具有持久性,它们代表了一个临时的通信通道,用于在通信的两端传输数据。
  • 具体来说,套接字是一组网络编程 API(例如,在C语言中是Socket API)的一部分,它允许应用程序创建、配置、连接和通信。套接字在操作系统内部实现了网络协议的细节,包括数据封装、路由、错误处理等。套接字允许应用程序通过网络发送和接收数据,但不是文件系统的一部分,也没有文件的特性。
  • 虽然套接字可以使用类似于文件的读取和写入操作,但它们的底层实现与文件系统完全不同。套接字提供了一种用于网络通信的抽象接口,而文件用于存储和管理数据。因此,套接字和文件是两种不同的概念,用于不同的用途。

底层又是怎么实现的呢?

套接字的底层实现是由操作系统提供的,它涉及操作系统内核中的网络协议栈和网络套接字库。不同的操作系统(如Windows、Linux、macOS等)可能有不同的实现细节,但一般来说,套接字的底层实现包括以下几个关键方面:

  1. 网络协议栈: 操作系统内核包含一个网络协议栈,用于处理网络通信。这个协议栈包括各种网络协议,如TCP(传输控制协议)、UDP(用户数据报协议)、IP(Internet协议)等。这些协议协同工作以确保数据在网络上的可靠传输。

  2. 套接字库: 操作系统提供了一个套接字库(Socket API),它是应用程序与底层网络协议之间的接口。开发者可以使用套接字库中提供的函数来创建、配置、连接和管理套接字。这些函数允许应用程序发送和接收数据,以及执行网络通信的各种操作。

  3. 套接字的创建和配置: 应用程序通过调用套接字库中的函数来创建套接字。套接字可以是TCP套接字或UDP套接字,它们可以绑定到特定的网络地址和端口。配置套接字包括设置套接字选项(例如,超时设置、缓冲区大小等)以满足应用程序的需求。

  4. 数据传输和路由: 当应用程序通过套接字发送数据时,数据被传递给操作系统内核的网络协议栈。协议栈根据目标地址和协议来选择正确的路由,将数据传输到目标机器上的套接字。在目标机器上,数据经过协议栈的处理,最终传递给接收套接字。

  5. 错误处理和状态管理: 套接字库和操作系统内核处理各种网络错误和异常情况,以确保通信的可靠性。套接字可以处于不同的状态,例如监听状态、已连接状态、关闭状态等,这些状态由套接字库和操作系统管理。

总的来说,套接字的底层实现涉及操作系统内核中的网络协议栈、套接字库以及与网络通信相关的各种数据结构和算法。这些组件协同工作,以实现应用程序之间的网络通信。不同的操作系统和编程语言提供不同的套接字API,但它们的底层实现原理大致相似。

基于TCP协议的socket编程的常见函数

setsockopt函数

tips:setsockopt函数的内容截取自笔者的另一篇文章<<传输层协议——TCP协议>>中标题为TIME_WAIT状态导致bind失败会引发什么问题呢?又该如何解决呢?的部分,如果下一段内容不是很理解,请详细阅读该篇文章中对应的内容。

服务端进程是需要有一种在进程退出后立刻能在相同的端口号上重启的能力的,为此,系统就给我们提供了一个接口,如下图所示。这个接口用于让我们在通过socket函数创建监听套接字listen_sock后,给这个listen_sock设置属性,比如说参数sockfd、level、optname表示的意思就是给指定的文件描述符(对应sockfd参数)在套接字层(对应level参数)上设置地址复用的功能(对应optname参数),把地址复用的功能打开。这个时候即使服务端进程挂掉了并且变成了TIME_WAIT状态,但因为TIME_WAIT状态不会再被使用了(即服务端会直接绕过TIME_WAIT状态的判断,比如说调用bind函数时,该函数内部肯定会进行if判断的,比如if查看需要绑定的端口号是否已经被占用了,比如if查看正在进行绑定的当前进程是否处于TIME_WAIT状态,调用setsockopt函数就是不让OS进行if判断,直接绕过TIME_WAIT状态的判断),所以这时服务端进程重启时bind绑定和之前相同的端口号是能bind成功的,于是服务端进程就能立刻在相同的端口号上重启了。

使用该函数时,固定的像下图红框处一样使用即可。

listen函数

双方进程基于TCP协议进行网络通信时就需要用到该函数,用于让通信的双方在正式通信前进行连接。

UDP服务端的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定;而TCP服务器和前面不一样,TCP服务器除了需要做这两步的操作外,因为TCP服务端是面向连接的,客户端在正式向TCP服务端发送数据之前,需要先与TCP服务端进行3次握手建立连接,然后才能与服务端进行通信,所以TCP服务端还需要时刻注意是否有客户端发来连接请求,此时就需要通过listen函数将TCP服务端进程创建(通过socket函数)并和当前进程进行过绑定(通过bind函数)的套接字文件设置为监听状态。做完这三步,TCP的服务端的初始化才算完成。

参数说明:

参数1、sockfd:也就是【需要被设置为监听状态的套接字文件】所对应的文件描述符。是哪个套接字文件需要被设置成监听状态呢?哪个套接字文件需要作为当前进程与其他主机上的某台进程通信的通信通道的一环,哪个套接字文件就需要被设置成监听状态,所以是当前进程(服务端)创建(通过socket函数)并和当前进程进行过绑定(通过bind函数)的套接字文件需要被设置成监听状态。

说一下,在TCP通信中,这个被设置成监听状态的套接字文件虽然也作为双方进程通信的通信通道的一环,但这个套接字文件并不用于直接传输双方收发的信息,该套接字文件做的工作为:

  • 监听是否有其他主机的某个进程的连接请求过来(即是否有人调用connect函数连我),说一下,不管有没有连接请求过来,这里调用listen函数都不会陷入阻塞,阻塞通常会发生在调用accept函数接受连接请求的部分,比如说一旦调用listen后,通常会使用accept函数来获取连接队列中的连接对象(在网络通信中是不止一个客户端会连接服务端的,服务端会和许多的客户端进行3次握手建立连接成功、即服务器上会创建许多的连接对象,服务端进程所在的OS会通过队列这种数据结构把这么多连接对象管理起来,服务端每次建立连接成功、即每创建一个连接对象成功时都会把该连接对象放到队列末尾,accept函数就用于让服务端进程从连接队列里获取一个位于队列首部的连接对象),如果连接队列不为空,则accept函数获取连接对象成功并直接返回一个新的套接字,该套接字用于和客户端通信;如果连接队列为空(也就是没有任何客户端和服务端进行3次握手建立连接成功),则调用accept函数的进程会在accept函数内部陷入阻塞,accept函数会在有新的连接对象被创建并放入连接队列时恢复运行并返回一个新的套接字,该套接字用于和客户端进行通信。
  • 所以综上所述,在调用了listen函数后,后序代码中会通过accpet函数创建一个socket套接字文件(这是需要程序员编码控制的,如果在编写代码时listen函数的下方没有调用accept函数,那程序员编码就有问题),让accept函数创建出的套接字文件去作为当前进程和某台主机上的某个进程之间通信的通信通道的一环,而我(即通过listen函数被设置成监听状态的套接字文件)则继续监听是否有其他主机的某个进程的连接请求过来。
  • 综上所述,因为被设置成监听状态的套接字文件并不直接用于传输当前进程和对端进程双方收发的信息(假设把这个操作称为任务),而是把这个任务交给了其他套接字文件,自己只是做了监听的工作,所以一般我们把被设置成监听状态的套接字文件称呼为监听套接字文件、把该文件对应的文件描述符称为listen_sock,把给监听套接字 “打工” 的套接字文件称呼为服务套接字文件、把该文件对应的文件描述符称呼为service_sock。

问题:有人可能会说【被设置成监听状态的socket套接字文件不过是个文件,文件只能进行读写操作,为什么该套接字文件可以做上面所说的那些工作呢?】答案:在上文中说过,不要混淆socket套接字和文件的概念,在不严格的视角下我们可以认为套接字就是文件,但在严格视角下并不能直接将套接字直接和文件划上等号。所以问题的答案也就很明显了,因为严格意义来说套接字并不是一个文件,所以通过套接字可以做到通过文件做不到的事情,所以套接字可以做上面所说的那些工作。

参数2、backlog:(关于该参数和连接队列的详细说明在下面accept函数处)可以通过该参数指定全连接队列的最大长度。如果有多个客户端同时发来连接请求,如果队列未满,则此时服务端就会和这些客户端建立连接、即创建一个连接对象并放入连接队列中;如果队列已满,服务端将不再和新的客户端建立连接,即新的客户端通过connect函数发来的连接请求将被服务端拒绝。该参数代表的就是连接队列的最大长度,通过设置适当的 backlog 值,可以控制服务器最多能存储的连接对象的数量,一般不要设置太大,设置为5或10即可,以防止存储过多的连接对象导致服务器的内存资源耗尽进而崩溃。

listen函数的注意事项

如果调用listen失败,是需要立刻让进程退出的,这是因为listen失败时,后序进行任何网络操作的结果都是未知的,是错误的,所以如果进程不退出,继续向下执行代码就会出现各种错误。而想让进程在调用listen失败时退出,就需要程序员编码控制,比如设置if分支,listen失败就进入if分支调用exit函数使当前进程退出;而不能指望系统,因为调用listen函数失败时,系统并不会自动结束当前进程,而是会继续向下执行代码,所以想让进程在调用listen函数失败时退出,就需要程序员编码控制。

返回值说明:

  • 监听成功返回0,监听失败返回-1,同时错误码会被设置。

listen函数的功能说明:

在TCP中,listen 函数用于设置监听套接字(listening socket)进入被动监听状态。监听套接字是服务器端用来接受客户端连接请求的套接字。当一个套接字处于被动监听状态时,它会一直等待客户端的连接请求,而不主动发起连接。

具体来说,以下是 listen 函数在TCP中的作用:

  1. 设置套接字为监听套接字: 在服务器端程序中,首先需要创建一个套接字并将其绑定到一个特定的端口,然后使用 listen 函数将该套接字设置为监听套接字。这告诉操作系统该套接字用于接受客户端的连接请求。

  2. 指定请求队列的最大长度: listen 函数的第二个参数 backlog 指定了连接请求队列的最大长度,通过设置适当的 backlog 值,可以控制服务器最多能存储的连接对象的数量,一般不要设置太大,设置为5或10即可,以防止存储过多的连接对象导致服务器的内存资源耗尽进而崩溃。

  3. 等待客户端连接: 一旦套接字被设置为监听状态,它就会一直等待客户端的连接请求。当客户端尝试连接到服务器的端口时,操作系统会将连接请求放入连接请求队列,然后服务器可以使用 accept 函数从队列中接受连接请求,并创建一个新的套接字来处理与客户端之间的通信。

总之,listen 函数在TCP中用于告诉操作系统某个套接字用于接受客户端的连接请求,并指定了连接队列的最大长度。这是建立服务器程序的基础步骤之一,以便能够处理多个客户端的连接请求。

accept函数

在网络通信中是不止一个客户端会连接服务端的,服务端会和许多的客户端进行3次握手建立连接、服务器上会创建许多的连接对象,服务端进程所在的OS会通过队列这种数据结构把这么多连接对象管理起来,服务端每次建立连接成功(即每创建一个连接对象成功)时都会把该连接对象放到队列末尾,上图的accept函数就用于让服务端进程从连接队列里获取一个位于队列首部的连接对象,如果连接队列不为空,则accept函数获取连接对象就会成功并直接返回一个新的服务套接字,该服务套接字就用于和客户端通信,注意因为服务套接字本质也是文件描述符,所以按照文件描述符的赋值规则,每accept一次后,下一次再accept获取到的文件描述符一定是比之前大一的。该函数的其他基本信息如下。

(注意关于何时调用accept会陷入阻塞的相关知识点在listen函数中已经进行过说明,这里不再赘述,如有需要,请回顾listen函数部分)


补充知识点:

1、在TCP通信中,通信双方会在建立连接时(即3次握手时)交换各自的ip和port信息,比如客户端在调用connect函数发起连接请求的时候(本质是在发送SYN标志位为1的TCP报文),OS会自动把客户端进程绑定的ip和port信息包含进连接请求中发给服务端进程,服务端进程调用accept函数从连接队列里获取连接对象时如果成功获取,则通过传给accept函数的输出型参数就能知道客户端的ip和port了。

2、accept函数是完全不参与3次握手的,早在服务端调用accept函数前,服务端和客户端双方就已经3次握手完毕了(即双方的连接就已经建立成功了,这是由OS控制完成的,用户无法干涉),换句话说就是即使服务端不调用accept函数,服务端和客户端也是能建立连接成功的(从这里可以看出,服务端的3次握手完成的时间点在listen函数后,accept函数前)。accept函数本质是用于获取已经建立好的连接,或者说本质是用于获取连接队列中的连接对象的(从这里可以发现,如果服务端和任意一个客户端3次握手都没有成功,没有一个连接建立完成,即没有一个连接对象被创建并放入连接队列中,那么此时服务端调用accept就会陷入阻塞)。那么问题来了,当不断有新的客户端调用connect函数向服务端发起SYN期望进行3次握手建立连接时,服务端都满足该客户端的期望和它建立连接吗?

回答这个问题前,先插入另一个问题并进行解答,问题为:连接队列可以很长吗?为什么要有连接队列呢?或者说可不可以不要连接队列呢?

答案:假设服务端的连接队列很长,其中有50w个连接对象,那么如果服务器的配置最多只支持服务端进程创建5w个子进程或者新线程提供服务,那么服务端一次性最多只能为5w个连接提供服务,在服务结束前,其他45w个连接就只能在连接队列中等待被accept获取、等待被服务。这就很坑爹了,毕竟我作为用户使用客户端时,你一直让我等待是几个意思,即使不用一直等,比如服务端为每个连接提供的服务只需要2s,那排在由45w个连接对象组成的队列的末尾的连接对象想要被服务,也需要等待2s*(45/5)=18s,一般来说用户是没有耐心等待的,所以连接队列不能很长;另外,你维护这么长的连接队列也是需要大量的内存等资源的,你既然有这么多资源,干嘛不把这些资源用于创建更多的子进程或者新线程呢?毕竟如果创建了更多的子进程或者新线程,那么服务端一次性就能为更多的连接提供服务,就能减少需要在连接队列中排队的连接对象、提高服务效率。所以综上所述,可以发现服务端的连接队列是不可以很长的。

然后要说的是,问题【为什么要有连接队列呢?或者说可不可以不要连接队列呢?】的答案也很简单,假设没有连接队列,那么服务端就不支持记录自己建立好的连接,那么就只能建立好一个连接就让某个子进程或者新线程为这个连接提供服务,当每个子进程或者新线程都在服务时,就不能再和其他客户端建立连接了(这是因为在不支持记录连接的情况下即使建立连接成功了,后序当有子进程或者新线程闲下来时也找不到该连接进而无法给该连接提供服务,于是服务端干脆此时拒绝客户端的连接请求),这就会导致当有新线程或者子进程服务完毕后闲下来时,如果没有客户端恰好在此时给服务端发起建立连接的请求,那么闲下来的子进程或者新线程就会继续闲下去、继续什么也不做,这样一来,服务端的子进程资源或者新线程资源就没有被充分利用;而如果存在连接队列,当每个子进程或者新线程都在服务时,就能再和其他客户端继续建立连接、就能多记录几个连接对象,这就会导致当有新线程或者子进程服务完毕后闲下来时,即使此时没有客户端恰好给服务端发起建立连接的请求,也只需要调用accept从连接队列里获取一个连接就能让子进程或者新线程继续工作、为该连接提供服务,充分利用子进程资源或者新线程资源,这就是为什么要有连接队列的原因。所以综上所述,可以发现服务端的连接队列是不可以没有的。

走到这里,中途插入的问题和答案就说明完毕了,咱们继续回答最初的问题【当不断有新的客户端调用connect函数向服务端发起SYN期望进行3次握手建立连接时,服务端都满足该客户端的期望和它建立连接吗?】

此时我们就能理解,服务端绝对是不可以无脑满足客户端的期望和它建立连接的,因为只要服务端对客户端发来的连接请求来者不拒,那么就会导致连接队列很长,就会出现上面的问题。另外,服务器每创建一个连接对象都是需要内存资源的,内存不是无限的,当然服务端也是不可以无脑满足客户端的期望和它建立连接的。

从上文我们可以发现,连接队列是不可以很长,也不可以很短的,那么如何控制连接队列的长度呢?答案是通过listen函数的第二个参数backlog,在调用listen函数时,backlog+1就是我们设置的连接队列的长度,比如说传给listen函数的参数是5,那么设置的连接队列的长度就是6。

接下来咱们通过代码证明一下上面所说的知识点【早在accept函数被调用前,3次握手就已经完成、双方的连接就已经建立成功了】和【在调用listen函数时,backlog+1就是我们设置的连接队列的长度】:

  • 代码如下图1所示,可以发现其中只调用了listen函数而没有调用accept函数。如下图2所示,当在8080号端口上运行服务端进程后,再通过telnet连接服务端,然后通过netstat命令就观察到了属于【8080端口对应的服务端进程】的连接的状态是ESTABLISHED,因为ESTABLISHED状态是表示连接已经建立成功的(即服务端已经创建出了连接对象),这就证明了【早在accept函数被调用前,3次握手就已经完成、双方的连接就已经建立成功了】;
  • 注意下图1的代码中我们还把backlog设置成了1(即把连接队列的长度设置为2),那么预期中的景象就是当有两个telnet成功和服务端建立连接后,即服务端创建两个连接对象并放入连接队列后,连接队列就被放满了,此时因为服务端永远不会调用accept从连接队列中获取连接对象,那么就会导致服务端无法再创建连接对象并将连接对象放入连接队列里,即导致服务端无法再和任何客户端完成3次握手以成功建立连接,可以看到下图3中当已经有两个telnet和服务端成功建立连接后,再通过第3个telnet去连接服务端时,调用netstat命令就能看到有一个属于【8080端口对应的服务端进程】的连接的状态是SYN_RECV,因为不是ESTABLISHED,所以表示没有建立连接成功,这就证明了【在调用listen函数时,backlog+1就是我们设置的连接队列的长度】。额外说一下,这条状态为SYN_RECV的连接是过一会就会被销毁掉的,这是因为操作系统通常会为SYN_RECV状态设置一个超时时间,如果在该时间内依然没有3次握手建立连接成功,则服务端的OS就会终止连接并释放相关资源,以避免无效的连接占用系统资源,所以过一会再通过netstat命令就查不到这条连接了。

最后再补充一点,TCP协议中有两条连接队列,分别是全连接队列和半连接队列,上文中所说的所有连接队列本质都是全连接队列(该队列用于存储处于ESTABLISHED状态的连接),而不是半连接队列。半连接队列是用于存储处于SYN_SENTSYN_RECV状态的连接的队列,在上文中举例时说当全连接队列满了的时候,服务端无法再和新的客户端进行3次握手成功以成功地建立连接,这时这条“半成品连接”就会被放入半连接队伍中,然后这条“半成品连接”过一段时间后(其原因在上一段中说过了)就会被服务端的OS完全销毁掉。

图1如下。

图2如下。

图3如下。 


注意事项:

  • 和socket、bind、listen、connect函数不一样,如果调用accept失败,是不能让进程退出的,而应该continue进入下一个循环,重新调用accept函数,每当accept成功获取到新连接时,就创建子进程或者新线程去处理这个连接,或者不创建它们,直接让主进程去处理这个连接,因为如果服务端获取连接失败就让服务端进程退出,那就太不合理了。

参数说明:

  • sockfd:该参数即是在讲解listen函数时所说的监听套接字文件所对应的文件描述符listen_sock,表示从该监听套接字文件中获取连接。
  • addr:作为一个输出型参数,用于在获取对端的连接请求时获取对端网络相关的属性信息,包括协议家族、IP地址、端口号等。注意如果不关心对端网络相关的属性信息,直接将该参数设置成nullptr即可。
  • addrlen:作为一个输入输出型参数,调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度。注意如果不关心对端网络相关的属性信息,直接将该参数设置成nullptr即可。

返回值说明:

  • 获取连接成功时(即accpet成功时)返回一个套接字文件的文件描述符(该文件描述符是在讲解listen函数时所说的给监听套接字文件 “打工” 的套接字文件对应的文件描述符service_sock);
  • 获取连接失败返回-1,表示发生了错误,并且错误码会被设置。这个错误可能是由于多种原因引起的,包括但不限于1、将listen_sock对应的套接字设置成非阻塞模式后,如果连接队列为空导致调用accept没有获取到连接对象时,此时accept函数就不会阻塞,而是直接返回-1并将错误码设置成EWOULDBLOCK(又称为EAGAIN)至于如何将监听套接字对应的文件描述符设置成非阻塞状态, 请参考<<高级IO的相关知识点>>一文中的非阻塞IO部分。2、 根据<<高级IO的相关知识点>>一文中非阻塞IO部分的内容可知,不管套接字是否被设置为非阻塞模式,如果调用accept函数时刚好来了一个信号,并且在代码中还设置了该信号的信号处理函数,则accept函数就会立刻停止被调用转而调用信号处理函数,当信号处理函数执行完后accept函数也不会被继续调用,而是直接返回-1并将错误码errno设置成EINTR。综上可以发现,如果accept返回-1,你应该根据具体的错误代码(可以通过 errno 获取)来确定发生了什么类型的错误,并相应地处理它。

connect函数

因为在客户端进程中,【客户端进程】与【ip、port、socket文件】的bind绑定不需要程序员显示调用,而是由OS自动绑定,又因为客户端进程中创建的套接字文件不是监听套接字文件,不需要监听,所以当客户端进程创建完套接字后就可以向服务端发起连接请求。 如上图所示,发起连接请求的函数叫做connect。

补充知识点:

  • 在TCP通信中,通信双方会在建立连接时(即3次握手时)交换各自的ip和port信息,比如客户端在调用connect函数发起连接请求的时候(本质是在发送SYN标志位为1的TCP报文),OS会自动把客户端进程绑定的ip和port信息包含进连接请求中发给服务端进程,服务端进程调用accept函数从连接队列里获取连接对象时如果成功获取,则通过传给accept函数的输出型参数就能知道客户端的ip和port了。

注意事项:

  • 如果调用connect失败,是需要立刻让进程退出的,这是因为connect失败时,后序进行任何网络操作的结果都是未知的,是错误的,所以如果进程不退出,继续向下执行代码就会出现各种错误。而想让进程在调用connect失败时退出,就需要程序员编码控制,比如设置if分支,connect失败就进入if分支调用exit函数使当前进程退出;而不能指望系统,因为调用connect函数失败时,系统并不会自动结束当前进程,而是会继续向下执行代码,所以想让进程在调用connect函数失败时退出,就需要程序员编码控制。

参数说明:

  • sockfd:客户端进程创建出的套接字文件所对应的文件描述符,表示通过该套接字文件向服务端进程发起连接请求以及进行后序的通信。
  • addr:对端进程(即服务端进程)的网络相关的属性信息,包括协议家族、IP地址、端口号等,传这个参数的作用是告诉connect函数该向谁发起连接请求。
  • addrlen:传入的addr结构体的长度。

返回值说明:

  • 获取连接成功则返回0。
  • 失败返回-1,同时错误码会被设置。

read函数

该函数在讲解基础IO的文章中已经讲过了,用于从文件描述符fd对应的文件里读取count字节的数据到buf指针指向的空间中,在TCP网通通信中也可以通过该函数从指定的用于收发信息的套接字文件中读取指定字节的数据。说一下参数count只表示用户期望从文件中读取到的数据的大小,但实际不一定真的读取了这么多,比如说如果fd文件中只有10字节的内容,但参数count为100,那么read函数只会读10字节,把fd文件读完后就结束。注意write函数的参数count则有点不一样,详情请见write函数处。

注意如果buf指向的空间是用于存储字符串,由于使用printf等函数打印字符串时,如果不遇到\0则一直打印,所以要在使用read函数之前将buf指向的空间全初始化成\0,不然之后打印buf时可能会有乱码。

注意有一个关于【read函数的使用注意事项】的知识点和一个关于【调用read函数导致当前进程或者线程陷入阻塞】的知识点,这两个知识点在下文中标题为面向字节流和面向数据报的部分,请移步去看,注意一定要看,很重要。如果关于【read函数的使用注意事项】的知识点不看,则编写出来的TCP通信代码一定是有问题的。

返回值说明:

  • ssize_t就是long int(注意ssize_t不是size_t),如果返回值大于0,则代表实际读取到了多少字节的数据。
  • (很重要,一定要看)如果返回值等于0,则代表给我当前进程write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,根据上文讲解的close函数的知识点,我们就知道对端调用close后,此时从对端进程的发送缓冲区到当前进程的接收缓冲区这一条信道就被销毁了,不会再有数据从对端进程的发送缓冲区到我当前进程的接收缓冲区里来了,此时当前进程调用read函数把接收缓冲区中的数据拿完后,再次调用read函数就会返回0。(注意不要混淆一点,如果当前进程调用read函数把接收缓冲区中的数据拿完后,再次调用read函数只是导致当前进程在read函数处阻塞而不是返回0,这是因为对端进程没有调用close关闭对端的套接字;而现在的情况是对端进程调用close关闭了对端的套接字,于是当前进程调用read函数把接收缓冲区中的数据拿完后,再次调用read函数就会返回0)额外回顾一下,在讲解管道的文章中说过,如果read读取管道文件中的数据时返回值是0,则也说明所有管道写端对应的文件描述符被close关闭。
  • 如果返回值等于-1,即<0,则表示发生了错误。这个错误可能是由于多种原因引起的,包括但不限于:1、套接字被设置为非阻塞模式并且套接字对应的接收缓冲区中目前没有可用数据。2、read函数的参数设置不正确,例如传递了无效的文件描述符。3、 根据<<高级IO的相关知识点>>一文中非阻塞IO部分的内容可知,不管套接字是否被设置为非阻塞模式,如果调用read函数时刚好来了一个信号,并且在代码中还设置了该信号的信号处理函数,则read函数就会立刻停止被调用转而调用信号处理函数,当信号处理函数执行完后read函数也不会被继续调用,而是直接返回-1并将错误码errno设置成EINTR。综上可以发现,如果read返回-1,你应该根据具体的错误代码(可以通过 errno 获取)来确定发生了什么类型的错误,并相应地处理它。
  • 再次强调:当接收缓冲区为空时,调用read函数如果没有阻塞,而是立刻返回,则说明要么是【对端close断开了连接,本地调用read把接收缓冲区中的数据读取完后,再次调用read就会返回0】,要么是【对端没有close,只是本地把要read读取的文件描述符设置成了非阻塞状态,当本地调用read把接收缓冲区中的数据读取完后,再次调用read就会返回-1】。至于如何将文件描述符设置成非阻塞状态, 请参考<<高级IO的相关知识点>>一文中的非阻塞IO部分。

write函数

该函数在讲解基础IO的文章中已经讲过了,用于向文件描述符fd对应的文件里写入【从buf指针指向的地址开始,到往后count个字节的数据】,在TCP网通通信中也可以通过该函数向指定的用于收发信息的套接字文件中写入指定字节的数据。说一下参数count表示用户期望写入文件的数据的大小,和read函数有稍许不同的是,如果write的参数count为100,那么即使buf中只有10字节的有效数据,write函数也会从buf的起点开始向后读取100字节写入到fd文件中,当然这后90字节的数据全都会是乱码。

注意事项:

  •  在<<对协议的基本认识>>一文中说过,因为不同主机上大小端的差异和内存对齐规则的差异,我们在应用层中是不能直接调用send或者write发送结构化类型的;同时因为不同主机上大小端的差异和为了尽可能的节省网络资源(即让发送的数据变小),所以我们在应用层中是不能直接调用send或者write发送int、double类型的数据的。将这些类型的数据发送到发送缓冲区前,都是需要先在应用层编码将这些数据转化成C语言字符串类型,然后再发送的,否则应用层的代码就编写得有BUG。

  • 注意有一个很重要的关于调用write函数导致当前进程或者线程陷入阻塞的知识点,该知识点在下文中标题为面向字节流和面向数据报的部分,请移步去看,注意一定要看,很重要。

(很重要,一定要理解)注意如果需要通过read接收当前进程发出的信息的对端进程中的套接字文件对应的文件描述符被close关闭了,则此时在当前进程中第一次调用write就会失败(注意不管当前进程是在write的途中对端进程的套接字才被关闭,还是对端进程的套接字先关闭当前进程才调用write,write函数都会失败;根据上文讲解的close函数的知识点我们就知道了此时虽然当前进程调用write会失败,但并不代表当前进程的发送缓冲区中剩余的数据不能被OS发送到对端的接收缓冲区中,因为此时当前进程没有调用close函数,双方都只完成了2次挥手,所以当前进程的发送缓冲区到对端的接收缓冲区这条信道是没有被销毁的,并且OS根据TCP协议是会将当前进程的发送缓冲区中剩余的数据发送给对端的接收缓冲区的,否则这些信息就丢失了,调用write失败只是为了不让你用户得寸进尺继续往当前进程的发送缓冲区中塞数据),注意此时虽然调用失败,但write函数能正常结束,即返回-1并将错误码errno设置成EPIPE,然后继续向下执行代码,但如果在当前进程中第二次调用write函数,则函数也会返回-1,但同时当前进程会收到OS发来的SIGPIPE信号(即13号信号),该信号的默认处理方式就是杀死当前进程。额外回顾一下,在讲解管道的文章中说过,如果write向管道文件中写入数据时管道的读端被关闭了,则管道的写端进程也会被OS杀死。

(很重要,一定要理解)问题:所以根据上一段的理论,如果客户端的套接字被关闭了(客户端的套接字会被关闭的情况有很多,比如客户端进程直接被ctrl c杀死了,那客户端的套接字肯定也是会被关闭的),服务端第二次调用write(一般提供服务时都是在while循环中,所以调用同一个函数多次是很正常的)向客户端发送信息时,服务端进程就会收到SIGPIPE信号从而根据信号的默认处理方式被OS杀死,这就出现了一种离谱的景象:因为客户端进程被关闭,导致服务端进程也被关闭了。这显然是不合理的,那如何解决这样的一个问题呢?即如何解决客户端进程被关闭时,服务端进程不会跟着被关闭,依然能正常运行呢?

(很重要,下面的每一点都要理解)答案如下:

  • (注意光靠方案一并不能完全解决问题,原因会在下面说)方案一:如果客户端的套接字被关闭了,此时服务端调用read函数并不会像调用write一样,是不会收到SIGPIPE信号的,也就不会因为该信号的默认处理方式导致服务端进程被OS杀死,在上面讲解read函数时说过此时调用read函数只是会失败并返回0。按照一般的服务端进程的逻辑,此时服务端在收到客户端的信息并进行了相关处理后是需要将处理的结果信息返回给客户端的,此时服务端进程就需要调用write函数向客户端发送这个结果信息,但根据上一段的理论,因为此时客户端的套接字被关闭了,所以此时服务端不能调用write函数向客户端发送信息,否则服务端进程就会被OS杀死。那么我们可以从代码的逻辑上控制此时不要让服务端通过write函数向客户端发送信息,比如既然我们知道了在服务端进程中调用read的返回值是0时表示客户端的套接字被关闭,那么我就设置一个if else分支,只有当read的返回值大于0时,服务端进程才可以进入if分支,才可以调用write函数,否则直接进入else分支,让为客户端提供服务的、服务端进程创建出来的子进程或者是新线程退出即可(为什么此时可以直接退出的原因在下面一段),这样一来,服务端就没法调用write函数,服务端进程也就不会因为客户端进程的退出导致服务端进程也被杀死了。
  • (本段是上一段中提出的问题的答案)为什么可以直接让服务端进程创建出的给客户端提供服务的子进程或者是新线程退出呢?难道子进程或者新线程的工作任务结束了吗?答案:因为客户端的套接字都已经被关闭了,也就是不打算访问服务端进程了,所以服务端也就不需要再向客户端提供服务了,为客户端提供服务的子进程或者新线程当然可以被销毁了。如果后序客户端打算重连服务端,那服务端也会重新创建出子进程或者新线程为客户端提供服务。
  • (光靠方案二就能完全解决问题)方案二:既然本质上服务端被OS杀死的原因是SIGPIPE信号的默认处理方式是杀死收到该信号的进程,而服务端进程会收到SIGPIPE信号,那我改变服务端进程收到SIGPIPE信号的处理方式,让该信号的处理方式中的逻辑是不要杀死当前进程不就行了?的确,这就是正解,比如我们可以直接调用函数signal(SIGPIPE,SIG_IGN),把服务端进程在收到SIGPIPE信号的信号处理方式从【杀死当前进程】改成【忽略】,即忽略该SIGPIPE信号,只要这样做就能完美地解决上面的问题,服务端进程就再也不会因为客户端进程的退出导致服务端进程也被杀死了。
  • 为什么说光靠方案一不能完全解决问题呢?举个例子,按照第一种方案的逻辑,在服务端中设置一个if else分支判断时,如果一开始客户端的套接字没有被关闭,一开始服务端调用read函数的确读取到了客户端的信息,返回值大于0,进入了if分支,即将调用write函数,但假如此时恰好客户端的套接字被关闭了,则服务端调用write函数不就坑了吗?此时服务端进程就会收到SIGPIPE信号,就只能被OS杀死了,所以光靠方案一并不能完全解决问题。
  • 注意虽然方案二能完全解决问题(即使用方案二后服务端进程就再也不会因为客户端进程的退出导致服务端进程也被杀死了),但服务端进程中为客户端提供服务的子进程或者是新线程该如何退出是需要解决的一个另一个问题,如果不解决,则子进程或者新线程就没法退出(因为子进程或者新线程提供的服务一般是在循环中,客户端的套接字被关闭后,服务端的子进程或者新线程调用read会返回0,调用write的话服务端进程也不会被杀死,依然继续执行代码,那循环就出不来了),这就会导致客户端进程退出后,服务端进程创建出的为客户端提供服务的子进程或者新线程却无法退出,一直无效地执行代码,一直占用资源。那该怎么解决这另一个问题呢?方案一就是这另一个问题的完美答案,根据方案一的逻辑,只要read返回0,就进入else分支,直接break,这样就走出了循环,所以一般经验而言,在编写服务端时,一定(注意是一定)要将方案一和方案二进行结合起来协同工作。

返回值说明:

  • ssize_t就是long int(注意ssize_t不是size_t),如果返回值大于0,则代表实际写入了多少字节的数据。
  • write函数的返回值不会出现为0的情况。
  • 如果返回值等于-1,即<0,则表示发生了错误,这个错误可能是由于多种原因引起的,包括但不限于:1、对端套接字文件被close关闭了,本地此时再调用write就会失败并返回-1。2、当套接字被设置为非阻塞模式并且当前发送缓冲区中没有可用的空间时,此时调用write函数就会直接返回-1。3、write函数的参数设置不正确,例如传递了无效的文件描述符(即先把本地的套接字对应的文件描述符先close掉,再将这个已经关闭的文件描述符传给write函数)。4、 根据<<高级IO的相关知识点>>一文中非阻塞IO部分的内容可知,不管套接字是否被设置为非阻塞模式,如果调用write函数时刚好来了一个信号,并且在代码中还设置了该信号的信号处理函数,则write函数就会立刻停止被调用转而调用信号处理函数,当信号处理函数执行完后write函数也不会被继续调用,而是直接返回-1并将错误码errno设置成EINTR。综上可以发现,如果 write 返回-1,你应该根据具体的错误代码(可以通过 errno 获取)来确定发生了什么类型的错误,并相应地处理它。
  • 再次强调:当发送缓冲区为满时,调用write函数如果没有阻塞,而是立刻返回,则说明要么是【对端close断开了连接,然后本地第一次调用write就会返回-1(注意如果第一次调用write后再次调用write,则也会返回-1,但同时OS会给当前进程发送SIGPIPE信号导致OS杀死当前进程)】,要么是【对端没有close,只是本地把要write写入的文件描述符设置成了非阻塞状态,当本地调用write把输入缓冲区写满后,再次调用write就会返回-1,并且错误码errno被设置成EAGAIN或者说EWOULDBLOCK】。至于如何将文件描述符设置成非阻塞状态, 请参考<<高级IO的相关知识点>>一文中的非阻塞IO部分。说一下,如果发送缓冲区还剩余100字节的空间,而你尝试write写入130字节,则write一个字节也不写入,如果此时套接字的属性是阻塞,那么此时调用write就会被阻塞,如果此时套接字的属性是非阻塞,那么此时调用write就会返回-1,并且将错误码设置成EAGAIN或者说EWOULDBLOCK。

recv函数

是用于在基于TCP通信中从目标主机上的进程接收信息的接口。 所有特性和上文中的read函数完全一致,只是对比write函数多了一个flag参数,用于表示以什么样的模式接收信息(比如阻塞、非阻塞、设置特定时间段),该参数一般不必关心,设置为0即可,0表示以阻塞模式进行接收,即如果没有数据可以接收,就陷入阻塞,直到有数据可以接收才恢复运行并接收数据。

当 recv 函数以非阻塞模式调用时,即使内核接收缓冲区中没有数据,调用recv函数也不会导致进程或者线程陷入阻塞,而是会立即返回-1并将错误码设置成EWOULDBLOCK(也被称为EAGAIN),只不过此时recv就没有【把内核缓冲区中的数据拷贝到应用层缓冲区中】的作用了;如果接收缓冲区中有数据,那么recv函数当然能调用成功了,此时recv函数会返回从内核接收缓冲区中读取到的数据的字节数量。

send函数

是用于在基于TCP通信中向目标主机上的进程发送信息的接口。 所有特性和上文中的read函数完全一致,只是对比write函数多了一个flag参数,用于表示以什么样的模式发送信息(比如阻塞、非阻塞、设置特定时间段),该参数一般不必关心,设置为0即可。

当send函数以非阻塞模式调用时,即使内核发送缓冲区已满,调用send函数也不会导致进程或者线程陷入阻塞,而是会立即返回-1并将错误码设置成EWOULDBLOCK(也被称为EAGAIN),只不过此时send就没有【把应用层中的数据拷贝到内核缓冲区中】的作用了;如果发送缓冲区没有满,那么send函数当然能调用成功了,此时send函数会返回发送到发送缓冲区中的数据的字节数量。

本地环回地址和INADDR_ANY地址

本地环回地址

本地环回地址就是值为127.0.0.1的ip地址。将服务端server进程的ip地址设置成127.0.0.1后,客户端进程client和服务端进程server收发数据时就只在本地协议栈中进行数据流动,不会把我们的数据发送到网络中。

本地环回的作用:主要用于在本地测试网络服务器,只要将服务端server进程的ip地址设置成127.0.0.1,此后如果客户端向服务端发送信息时,服务端能收到信息,那么这个网络服务器的编写逻辑就有99%的可能性是正确的。在本地测试通过后,如果在网络中测试发现无法正常收发信息,则有99%的可能性是因为网络不好。

INADDR_ANY地址

(说一下,当前进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问当前进程,是还可以让本地(即本机)上的其他进程访问当前进程的。也就是说当前进程bind绑定INADDR_ANY地址后就涵盖了当前进程bind绑定本地环回地址的功能。)

INADDR_ANY就是指定地址为0.0.0.0的地址,这个地址事实上表示不确定地址,或者说可以表示“所有地址”、“任意地址”。INADDR_ANY是个宏, 一般来说,在各个系统中均定义成为0值,如下图所示。

INADDR_ANY地址的作用:当一台机器的带宽足够大时,一台机器接收数据的能力就决定了这台机器的IO效率,因此一台服务器底层可能装有多张网卡,有几张网卡就有几个ip地址。对于一个进程来说,如果将当前进程和本机的某个固定的ip地址进行bind绑定,那么当前进程只能从这个固定的ip对应的网卡中接收该信息,那么当有网络中的其他主机上的进程想向本机的当前进程发送信息时,只有其他主机上的进程在发送信息时指定的ip是这个固定的ip,并且指定的端口号port对应的是当前进程,本机上的当前进程才能收到该信息;

而如果本机上的当前进程不和某个固定的ip地址进行bind绑定,而是bind绑定INADDR_ANY地址,那么当前进程可以从本机的任意一个ip对应的网卡中接收该信息,那么以后其他主机上的进程想向本机上的当前进程发送信息时,只要其他主机上的进程在发送信息时指定的ip是属于本机的、指定的端口号port对应的是当前进程,那么不管是从哪个网卡(ip)中收到的数据,都统统交给当前进程,本机上的当前进程都能收到该信息。

因此服务端绑定INADDR_ANY这种方案也是强烈推荐的方案,所有的服务器具体在操作的时候用的也就是这种方案。在编写服务端进程时,除了一些特殊场景,基本都是让服务端进程bind在绑定ip时绑定INADDR_ANY地址。

为什么云服务器上的进程在bind绑定INADDR_ANY后,其他主机就可以通过云服务器的虚拟的ip地址访问该进程了呢?

(说一下,当前进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问当前进程,是还可以让本地(即本机)上的其他进程访问当前进程的。也就是说当前进程bind绑定INADDR_ANY地址后就涵盖了当前进程bind绑定本地环回地址的功能。)

当进程在云服务器上绑定到INADDR_ANY后,它会监听在该服务器(即主机)上所有可用的网络接口上的连接请求,包括云服务器的物理网络接口和虚拟网络接口。这是因为INADDR_ANY表示进程可以接受来自本地或者网络的任何连接请求,而不限制于特定的IP地址。

云服务器通常会有一个虚拟IP地址(或者说公共IP地址),这个IP地址是公开可访问的(但不能被进程bind绑定),所以其他主机可以通过该虚拟IP地址访问云服务器上的进程。当其他主机向云服务器的虚拟IP地址(或者说公共IP地址)发送请求时,云服务器上的进程会接受这些请求,因为该进程已经绑定到INADDR_ANY,可以接受来自来自本地或者网络的任何连接请求。

这种方式使得云服务器上的进程可以被外部主机访问,这对于提供公共服务或多网卡服务器来说非常有用。总之,绑定到INADDR_ANY的进程可以接受来自所有可用网络接口的连接请求,包括云服务器的虚拟IP地址所在的网络接口,从而允许其他主机通过该虚拟IP地址访问它。

云服务器上的进程bind绑定云服务器的公网IP失败的问题

说一下,如下图,云服务器上的进程是无法bind绑定云服务器的公网IP的,只能bind绑定【本地回环ip地址127.0.0.1】和【INADDR_ANY地址0.0.0.0】。为什么呢?因为云服务器上的公网IP实际上是厂商虚拟出来的,并不是真正的公网IP,当然无法bind成功了。

有人可能会说【既然云服务器上的进程不能绑定云服务器的公网IP,那我就bind绑定一个普通主机的公网IP】,这里笔者想说的是:因为权限问题或者其他原因,本机上的进程如果绑定其他主机的ip一般都是绑定失败,并且就算绑定成功也没有意义,因为本机上的进程绑定了其他主机的ip,那么给本机发送信息的进程就不会把信息发到本机上,而是发到了其他主机上,而其他主机压根不会搭理这个信息,最终就导致本应该通信的双方进程压根就无法正常通信。

面向字节流和面向数据报(包含发送缓冲区和接收缓冲区的知识点)

面向字节流(Stream-Oriented)和面向数据报(Message-Oriented)是两种不同的通信模式,通常用于描述网络通信协议的特性和行为方式,前者是TCP协议下的通信模式,后者是UDP协议下的通信模式。

先说说面向字节流这种TCP协议下的通信模式

TCP协议全称为Transmission Control Protocol,翻译过来叫传输控制协议,既然名字中有传输控制这几个字,该如何体现呢?请往下看。

平时我们调用send或者write发送数据时,不要认为数据是直接被发送到了网络甚至是对方的主机中;同理平时我们调用read或者recv接收数据时,不要认为数据是直接从网络甚至是对方主机中读取到的。

调用send或者write需要传入一个地址是因为send或者write是要将这个地址中的数据发送出去,调用recv或者read需要传入一个地址是因为recv或者read是要将读取到的数据存进这个地址,那问题来了,send或者write是要把数据发送到哪呢?read或者recv是从哪读取到的数据呢?

(结合上图思考)当在TCP通信中创建一个socket文件时(通过socket函数的参数可以确认是TCP通信还是UDP通信),除了socket套接字文件会被创建,同时还会在内核中创建一个发送缓冲区和一个接收缓冲区,send或者write就是把数据发送到这个发送缓冲区中,而read或者recv就是从接收缓冲区中读取数据。

所以本质上send或者write根本就不是将数据发送到网络甚至是对方主机,而只是将数据从应用层的缓冲区(比如可以是字符数组)一次性全拷贝到内核的发送缓冲区中,做完这些send或者write函数就已经调用结束了,至于后序OS什么时候将发送缓冲区中的数据发送给套接字文件、然后数据再经过网卡发送到网络中,一次发送多少,出错了该怎么办,这些都不是应用层该操的心,而是由TCP协议说了算,由TCP控制,所以TCP才能被称为传输控制协议;

同理,本质read或者recv根本就不是直接从网络甚至是对方的主机中读取数据,而是从内核中的一个接收缓冲区中读取,把内核中的一个接收缓冲区中的数据一次性全拷贝到应用层的缓冲区中(比如可以是字符数组),做完这些read或者recv函数就已经调用结束了,至于OS什么时候将这些数据从网络中读取到网卡、然后数据再经过套接字文件被接收到接收缓冲区中,一次接收多少,出错了该怎么办,这些都不是应用层该操的心,而是由TCP协议说了算,由TCP控制,所以TCP才能被称为传输控制协议。

综上可以发现【read或者recv】和【write或者send】这些IO函数本质压根就不具备将数据发送到网络或者从网络中读取数据的功能,它们只是单纯的将数据一次性从本地(本机)的一个缓冲区中拷贝到本地的另一个缓冲区中。同时根据上面的理论可知在收发信息时【什么时候收/发,一次收/发多少,出错了怎么办】这些问题都不归应用层管,而是完全按照TCP协议的规则,那么在TCP通信模式下,双方进程在通信时互相收发信息的次数就没有任何关系,次数完全不需要一致了:

  • 比如当进程A调用10次write或者send向进程B发送10次消息时,进程B调用read或者recv接收这些信息所需的次数可能是任意次的,但即使这样,最后双方收发信息的工作在TCP协议的保证下也能正常完成。
  • 对于上一段的说法的进一步说明:说具体点就是进程A调用第10次write或者send前,根据TCP协议的规则,OS都没有将发送缓冲区中的数据发送给socket文件进而再发送到网卡以及网络中,而是直到第10次调用write或者send后,终于达成了某种条件,OS才将发送缓冲区中的数据发送给socket文件进而再发送到网卡以及网络中,一次发多少,什么时候发完全只看TCP协议怎么规定,然后进程B所在的主机根据TCP协议的规则,就将这些网络中的数据接收到网卡进而再接收到套接字文件进而再接收到内核缓冲区中,注意因为这些数据一次被接收多少进入接收缓冲区(即被分成几次拷贝进接收缓冲区),什么时候进行接收都是完全看TCP协议怎么规定,所以应用层调用recv或者read函数从接收缓冲区中拿数据时可能是一次拿不完的(因为虽然recv或者read函数能一次把接收缓冲区中的所有数据拿完,但问题在于接收缓冲区中的数据可能本身就是不完整的),所以之前才说进程B需要调用read或者recv函数接收这些信息所需的次数可能是任意次的。

像这种双方进程在通信时互相收发信息的次数没有任何关系的通信模式,就被称为面向字节流通信模式。

以前我们讲解文件时,也喜欢说读写文件是流式读写,就是因为上层应用层调用write函数写文件时,本质压根就不是直接把数据写入到文件里,而是把数据从上层应用层中的某个缓冲区(就是某个数组)拷贝到内核层的某个缓冲区里,然后write函数的任务就结束了,后序这些数据什么时候被发送到磁盘上、一次发多少、出错了怎么办,都完全由OS决定;就是因为上层应用层调用read函数读文件时,本质压根就不是从磁盘上读取文件、不是把磁盘上文件里的数据读取到上层应用层中的某个缓冲区里(就是某个数组),而是从内核层的某个缓冲区里读取数据、把内核缓冲区里的数据拷贝到上层应用层的某个缓冲区里,然后read函数的任务就结束了,至于内核缓冲区的数据是怎么来的,即什么时候从磁盘上读取数据放到内核缓冲区、一次读取多少、出错了怎么办,都完全由OS决定。(注意因为管道本质也是文件,所以对管道调用write或者read函数进行读写时,其底层做了些啥也是和前面完全相同的)

(结合上图思考)通过上面的理论我们还可以得到一个结论,这也是在使用read或者recv函数时的注意事项:

  • 实际在编写代码的时候,如果双方进程在TCP通信时一方进程在调用read或者recv接收另一方的信息时只调用了一次read或者recv,这样的编码就是有问题的,因为你不能保证这一次read或者recv函数能把信息读取完整(因为虽然read或者recv能一次把内核中的接收缓冲区中的数据读完整,但接收缓冲区中的数据可能本身就是不完整的),所以实际在编写TCP通信的代码时,调用read或者recv都是会嵌套在循环中的,read或者recv一次后就进行检查,查看数据是否读取完整(是否完整取决于我们的协议,比如我们约定length\r\n_x _op _y\r\n就是一个完整的数据),如果没有读取完整则直接continue进入下一次循环,然后再调用read或者recv函数继续读取,然后把第二次读取到的数据拼接到第一次读取的数据末尾,然后再检查数据是否读取完整...直到确认数据读取完整后,此时就不必continue了,继续执行循环中的后序代码,继续完成业务。

(结合上图思考)同理,通过上面的理论我们还可以得到一些结论。

调用send或者write函数可能会导致调用该函数的进程或者线程陷入阻塞,只要调用send或者write函数的进程或者线程的发送缓冲区被数据充满了,则再调用send或者write函数就会导致这个调用该函数的进程或者线程陷入阻塞:

  • 比如当网络比较拥堵的时候,如果调用了多次write函数把内核的发送缓冲区几乎填满了,此时再调用write或者send函数就会导致当前进程或者当前线程陷入阻塞;再比如本地一直在调用send或者write给对端主机发送信息,但对端主机不主动调用read或者recv从接收缓冲区中把这些数据取出来,那么就会导致对端主机的接收缓冲区充满,那么就算此时网络很通畅,本地的发送缓冲区中的数据也无法发送到网络进而再发送到对端主机的接收缓冲区中(无法发送是因为TCP协议有流量控制机制,如果接收缓冲区已满,发送方会被限制,从而导致发送方阻塞),那么本地在调用send或者write把本地的发送缓冲区充满时,再调用write或者send函数就会导致这个调用该函数的进程或者线程陷入阻塞。

调用read或者recv函数可能会导致调用该函数的进程或者线程陷入阻塞,只要调用read或者recv函数的进程或者线程的发送缓冲区中没有数据,则再调用read或者recv函数就会导致这个调用该函数的进程或者线程陷入阻塞:

  • 这里就不再举例,调用read或者recv函数导致当前进程或者线程陷入阻塞的例子看讲解send或者write函数时所举的例子就能脑补出来。

再来说说面向数据报这种UDP协议下的通信模式

应用层交给位于传输层的UDP协议多长的报文,UDP协议就得原样发送多长的报文,既不能拆分,也不能合并,这就叫做面向数据报。比如根据UDP协议传输100个字节的数据时,如果发送端调用一次sendto一次性地发送100字节,那么接收端也必须调用一次recvfrom一次性地接收100个字节,而不能循环调用10次recvfrom,每次只接收10个字节。

UDP协议的缓冲区

  • UDP协议的代码中没有定义真正意义上的发送缓冲区,这是因为UDP协议的特点就是无连接,不保证可靠交付,也就不需要一个发送缓冲区来记录历史上的数据以能在丢包时进行重传或者进行其他操作。因此,调用sendto函数会直接将数据交给内核,此时sendto函数的任务就结束了,后序由内核根据UDP协议的策略将数据传给网络层协议进行后续的传输动作。
  • UDP协议的代码中定义了接收缓冲区,但是这个接收缓冲区不能保证主机A收到UDP报文的顺序是和其他主机B给主机A发送UDP报文的顺序一致(这是因为报文在网络中进行路由转发时,并不是每一个报文选择的路由路径都是一样的,因此报文发送的顺序和接收的顺序可能是不同的),并且如果接收缓冲区满了,再到达的UDP数据(或者说UDP报文)就会被丢弃。

调用socket函数创建基于UDP协议下的socket套接字文件后(通过socket函数的参数可以决定是创建基于UDP协议下的socket文件还是创建基于TCP协议下的socket文件),我们发现不管是sendto写入还是recvfrom读出,都是对同一个UDP的socket套接字文件,这说明了UDP协议下的socket文件是全双工的、UDP通信是全双工的(底层原理在下下段中)。

全双工(Full Duplex)和半双工(Half Duplex)是两种数据通信的模式,用于描述数据在通信中的流动方式。它们的主要区别在于数据可以在通信通道中的传输方向和时间上的限制。

  • 全双工(Full Duplex):在全双工通信中,数据可以同时在两个方向上传输,即同时支持发送和接收操作。这意味着通信的双方可以同时发送和接收数据,而且这两个操作是相互独立的,互不干扰。一个常见的例子是吵架。当你与某人吵架时,你可以同时说话(发送数据)和听对方说话(接收数据)。
  • 半双工(Half Duplex):在半双工通信中,通信双方共享同一个通信通道,同一时间只能在一个方向上传输,只有一方可以发送数据,而另一方必须等待,直到发送方完成数据传输,接收方完成数据接收,然后接收方才能发送自己的数据,总之,不管是谁发,反正同一时间只有一方能发,而只要一方抢先发了,那另一方只能先等待进行接收,等到对方说完。这就是通信的半双工性质。一个常见的例子是对讲机,当A按下对讲按钮并说话时,是听不见B说话的,同时B想要听见A说话,B就不能在A正说话的时候也说话,只能先等A说话,然后B才能进行回应。

在上文中说过,调用socket函数创建一个socket文件时,除了会创建socket文件本身,还会创建发送缓冲区和接收缓冲区。而想要让一个socket文件是全双工的,只要让发送缓冲区和接收缓冲区不是同一个缓冲区即可,这是因为这样一来发送到网络中的信息和从网络中接收到的信息就不会经过同一个缓冲区了。从这里可以看出TCP协议下的socket套接字文件肯定就是全双工的(因为通过socket函数创建基于TCP协议下的socket文件时,该函数就还会在内核中创建一个输出缓冲区和输入缓冲区,发送到网络中的信息和从网络中接收到的信息也不会经过同一个缓冲区,同时在UDP协议这里,虽然通过socket函数创建基于UDP协议下的socket文件时该函数不会创建发送缓冲区,但在UDP协议下,主机A向主机B发送信息时信息并不会经过接收缓冲区,而是直接从应用层交给内核,所以发送信息时也不会对接收缓冲区有干扰,所以UDP协议下的socket文件也是全双工的、UDP通信也是全双工的。

守护进程(包含终端、bash、前后台进程、进程组、会话的概念)

讲解守护进程前,还需要介绍几个知识点。

1、控制终端和bash:

  • 控制终端:linux中的控制终端是指linux操作系统中用户与操作系统进行交互的界面,Linux通常提供一个文本界面或者说命令行界面(即控制终端),使用户可以通过命令行输入命令来控制系统的各种操作、可以在其中输入命令并查看命令的输出。终端本身不是一个进程,而是一个用户界面,它允许用户启动并与进程进行交互。
  • bash:bash是一种命令行解释器(shell),它是终端窗口中的默认shell。当你在终端中输入命令时,终端会将这些命令传递给bash来执行。bash是一个进程,它解释和执行用户输入的命令。每个终端窗口都会运行一个bash进程,这个bash进程会与用户交互并执行用户的命令。所以,终端和bash是密切相关的,但它们是不同的概念。终端提供了一个用户界面,而bash是一个用于执行命令的程序。用户在终端中输入的命令会被传递给bash进程来处理。当你打开一个终端窗口时,实际上就启动了一个bash进程,它就成为了该终端窗口的shell(即命令行解释器)。需要注意的是除了bash之外,还有其他类型的shell(即命令行解释器),用户可以选择在终端中使用不同的shell(即命令行解释器)。每个shell都有自己的特性和功能,但它们都是用于与用户进行交互并执行命令的程序。

2、和终端关联的进程就是前台进程(这是比较官方的说法),说通俗点就是能获取用户输入的数据的进程就是前台进程,比如bash进程(我们在一个shell界面中输入命令本质就是在bash进程中输入)。

在典型的操作系统中,一般情况下只允许一个前台进程与用户的终端(或控制终端)进行交互。这是出于操作系统的设计和安全考虑,以确保终端不会被多个进程混乱地使用,而且用户可以明确地与一个进程进行交互。

当你在终端中运行一个命令时,该命令通常会成为前台进程,并且会接收来自终端的输入和把输入的指令的执行结果向终端输出。只有当前台进程退出(比如ctrl c或者正常运行完退出)或被挂起(比如ctrl z暂停)时,才能在终端中运行其他命令。操作系统通常允许在后台同时运行多个进程,这些进程不与终端进行交互,因此它们是后台进程(虽然后台进程和守护进程都是不能和终端进行交互,但它们是有区别的,在于用户退出登录时后台进程会被关闭,而守护进程却不会被关闭,如果不太理解,建议先把下文阅读完再回过头来看),后台进程通常用于执行一些长时间运行的任务。

总的来说,操作系统通常只允许一个前台进程与用户终端进行交互,但可以同时运行多个后台进程,这是为了维护终端的有序性和用户友好性。不过你可以使用各种命令和工具来管理进程,例如fgbg命令,以及作业控制工具,来切换前台和后台进程。

3、进程的PCB信息中记录了很多信息,除了有进程ID(PID)、父进程的PID(PPID)、还有一个组ID(PGID),组ID相同的进程就都属于同一个进程组。进程组是信号管理的单元,在Linux系统中,信号是一种进程间通信的机制,用于通知进程发生的事件,设置进程组可以将一组相关的进程组织在一起,以便可以向整个进程组发送信号,这使得信号管理更加灵活,可以同时影响多个相关进程,而不是单独发送给每个进程。当一个父进程创建子进程时,通常会将子进程放入与父进程相同的进程组。这有助于在父子进程之间共享一些资源或状态,同时也方便了子进程的管理。

(结合下图思考)在命令行中同时用管道启动多个进程时,多个进程是兄弟关系(它们的PPID相同可以证明这一点),父进程都是bash(下图可以证明,额外一说就是因为这3个sleep进程有同一个父进程,所以才能用匿名管道 | 这种只能在具有血缘关系的进程之间通信的通信模式),而像这样的同时被创建的多个进程就是在同一个进程组中(它们的组ID(PGID)相同就可以证明),组长一般是第一个启动的进程(sleep 1000的PID和PGID相同就可以证明)。

5、任何一次登陆Linux机器时,登陆的用户都需要有多个进程或者说进程组来给这个用户提供服务,比如用户自己启动了很多进程或者进程组。所有给用户提供服务的进程或者用户自己启动的进程整体都是要属于一个叫做会话的机制中的。

上图中的SID就是会话ID,在Linux和类Unix操作系统中,通常情况下由同一个用户自己启动的所有进程都会具有相同的会话ID(Session ID),这是因为每个用户登录到系统时,通常会创建一个新的会话,并在该会话中启动用户的所有进程,在启动这些进程时,这个会话的会话ID就会被分配给会话中的所有进程,使它们属于同一个会话。但是,一些特殊情况下可能会有不同的会话ID,例如使用setsid函数,这个在下文中会进行讲解。

6、(结合上图思考)说一下:

当一个用户登录Linux机器时,OS会为该用户创建一个会话,然后在该会话中创建一个终端和【为该终端解释用户输入的文本的bash进程】,后序该用户自己会启动若干进程去做用户想做的事情(注意虽然这若干个进程都属于同一个会话,但这若干个进程可能属于不同的进程组)

当用户退出时,整个会话都将被销毁,里面的所有东西(或者说资源)都将不复存在,这也是为什么在日常生活中使用Windows的电脑时,如果电脑很卡,可以先试试注销电脑然后重新登录,因为注销就对应这里所说的用户退出,所以注销就会让分配给该用户的会话中的所有资源全部释放,资源释放后系统的压力就减轻了许多,就可能不再卡顿了,但如果注销并重新登录后还是很卡,则就需要重启电脑了。

说一下,上一段这种策略会引发一个问题,对于一个服务器来说,如果因为用户A退出,导致为用户A分配的会话A中的服务进程也被释放了,那不就坑了吗,服务器就不能提供服务了,这是万万不能的,所以就有一种方案可以把分配给用户A的会话A中的进程拿出来,并为该进程创建另一个会话B,再将该进程单独放到会话B中,这样一来即使用户A退出后OS释放了会话A中的所有资源,但因为该进程在会话B中,所以该进程也不会受到任何影响,能继续提供服务。

————分隔符————

走到这里,前置知识点已经全部讲解完毕,也就终于可以把守护进程是什么说明白了。上一段这种方案就叫做把某进程设置成守护进程,所以综上可以发现守护进程就是一个不会因为用户退出而被销毁的进程。

如何将一个进程设置成守护进程呢?可以通过下图函数,咱们来介绍一下。

该函数不需要参数,返回值就是调用该函数的进程的PID。如上图红框处所说,该函数的功能就是创建一个新的会话,然后把当前进程单独放到这个会话中,让该进程自成一个会话,然后让该进程单独自成一个进程组。

注意使用这个函数时有一个点要注意,就是如果想要调用setsid成功,必须保证调用该函数的进程不是它所属的进程组的组长,否则该函数就会调用失败。既然如此,那我想让一个服务进程变成守护进程就必须保证它不是它所属的进程组的组长,该如何保证呢?

  • 首先要知道父进程通过 fork 创建子进程时,子进程的内核数据结构(就包括PCB)在被创建时会直接拷贝父进程的内核数据结构里的信息(就包括PCB),所以子进程的PCB就会复制父进程的进程组ID(PGID)信息,所以子进程就继承了父进程的PGID,所以父子进程一定是属于同一个进程组的。
  • 然后要知道,在前置知识点中也说过,一般同时被创建的多个进程就是在同一个进程组中,组长一般是第一个启动的进程。在上一段中我们证明了父子进程一定是属于同一个进程组的,然后我们又知道在多进程编程中,父进程一定是最先启动的,所以只要能保证服务进程不是父进程,而只是一个子进程就能保证它不是它所属的进程组的组长,所以通常为了让一个进程不是进程组的组长,可以按照以下步骤进行:1、创建子进程 在父进程中使用 fork 创建一个子进程。2、父进程退出: 在子进程中,父进程应该立即退出,这样子进程就成为孤儿进程,不再有父进程。3、创建新会话: 在子进程中调用 setsid 函数,这将使子进程成为一个新的会话的领头进程。

下面是一个简单的示例代码:

#include <unistd.h>
#include <stdlib.h>

int main() {
    // 创建子进程
    pid_t pid = fork();

    // 如果 fork() 失败
    if (pid < 0) {
        // 处理错误
        exit(EXIT_FAILURE);
    }

    // 如果是父进程,立即退出
    if (pid > 0) {
        exit(EXIT_SUCCESS);
    }

    // 子进程继续执行,成为孤儿进程

    // 创建新会话
    if (setsid() < 0) {
        // 处理 setsid 错误
        exit(EXIT_FAILURE);
    }

    //创建会话成功,此时子进程就已经是一个守护进程了,后序开始处理业务

    return 0;
}

说一下,虽然上面的代码能将一个进程成功的变成守护进程,但是这个代码并不完善,因为该守护进程可能会因为一些原因而被异常关闭,这是我们不想看到的情况。为什么会导致异常关闭呢?又该如何解决这个问题呢?请继续往下看。

为守护进程单独创建会话时是不会为该会话创建任何终端和shell(给终端解释用户输入的文本或者说指令的命令行解释器,bash就是其中的一种)的,即守护进程所在的会话中是不包括任何终端和shell的,这是因为守护进程的主要目的是在后台执行任务,而不需要与用户的终端交互。也就是因为守护进程所在的会话中没有任何终端,所以用户没法通过终端向守护进程发送信息,守护进程也无法通过终端将信息打印在显示器上,并且如果守护进程尝试调用cout、cerr或者其他输出函数向显示器上打印信息,则会导致守护进程被暂停甚至是被关闭,所以在编写一个要被设置成守护进程的程序时,需要将标准输入、标准输出、标准错误这三个文件重定向,以防止与终端有关联(回顾一下,防止的原因是因为守护进程所在的会话中没有终端)。

那重定向到哪呢?答案:将三个文件全部重定向到/dev/null文件中。

  • (结合下图思考)只要是Linux系统的主机,则其中都会有一个/dev/null文件,该文件的特点是所有输入到其中的数据都会被丢弃,从该文件中读取数据时不会阻塞,但什么都读不到。该文件就仿佛一个文件黑洞一样,随便我们操作,并且不会影响系统的正常运行。

重定向完毕后,守护进程中如果再有cout、cerr等等向显示器上打印语句的函数,则不会打印到显示器上,而是打印到/dev/null文件里。

将一个进程变成守护进程的完整流程(代码)

结合上面的思路,我们能编写出一个完善的让一个进程成为守护进程的函数,如下面的myDaemon函数。

什么时候通过下面的函数将一个进程变成守护进程呢?答案:一般来说在所有逻辑开始前调用该函数就是一种选择,即进入main函数就调用该函数。

#include<iostream>
using namespace std;
#include<signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

void myDaemon()
{
    signal(SIGCHLD,SIG_IGN);//主动忽略CHLD信号,父进程就不需要进行进程等待以此回收子进程了
    if( fork() > 0)
        exit(0);
    else//子进程走这里
    {
        //将子进程设置成守护进程
        setsid();
        //将标准输入、标准输出、标准错误这三个文件重定向到/dev/null文件,防止守护进程向显示器打印进而守护进程被关闭
        int devnull = open("\dev\null", O_RDONLY | O_WRONLY);
        dup2(devnull, 0);
        dup2(devnull, 1);
        dup2(devnull, 2);
        //走到这里文件描述符表(即数组)的0、1、2号下标上就都是devnull文件的地址了,所以直接将devnull号(即3号)下标上的地址设置成nullptr即可
        close(devnull);
    }
}

如何证明已经将一个进程变成了守护进程呢?

举个例子:如下图1是我们编写的一个基于TCP协议的网络计算器,它是作为服务端进程的。在下图代码中我们通过上面编写的myDaemon函数将该服务端进程变成守护进程后,当直接在xsheel中通过./CalServer 8080运行它时,会发现该进程直接就退出了,就如下图2。注意这是正常的现象,因为我们通过./CalServer 8080运行该服务进程时实际上启动的是CalServer父进程,而在编写上面的myDaemon函数的代码时我们设计的逻辑就是在父进程创建子进程后就立刻让父进程退出,所以下图的代码中调用myDaemon函数后,CalServer父进程就直接退出了,后序变成守护进程并进行服务的进程实际上是CalServer子进程。

如何证明CalServer子进程变成了守护进程呢?答案:如果创建CalServer子进程的用户退出登录后,其他主机还能访问CalServer子进程,那就能证明CalServer子进程是守护进程;如果不能访问,则不是守护进程。说一下,如下图3右边,有一个CalServer 8080的进程,这就是CalServer子进程,TTY表示是否和终端有关联,而CalServer 8080进程的TTY是个,这就表示和终端无关(反之如果不是,则表示该进程和终端有关联);同时CalServer 8080进程的PGID和PID相同,这表示该进程单独自成一个组;同时CalServer 8080进程的SID和PID相同,这表示该进程单独自成一个会话。可以发现这些景象就呼应了上文中讲解的守护进程原理(即讲解守护进程时的前置知识点)。

从下图3中还能发现CalServer 8080进程的PPID是1,也就是系统,这说明CalServer 8080进程是一个孤儿进程(从上面咱们写的myDaemon函数的编码逻辑也能证明是孤儿进程,因为逻辑是父进程创建出子进程后就立刻退出,但子进程是要一直提供服务的,所以子进程就成了孤儿进程),从这里可以得到一个结论:守护进程本质是孤儿进程的一种,它和孤儿进程的区别在于孤儿进程是属于某个会话,而守护进程是单独自成一个会话。

  • 图1如下。
  • 图2如下。
  • 图3如下。

如何将一个守护进程关闭呢?

现在连用户退出登录(比如把xshell关闭)都无法把守护进程关闭,那该如何把守护进程关闭呢?

很简单,通过ps命令查看守护进程的PID,然后kill -9 PID即可杀死它。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值