
目录再谈端口号一、传输层协议UDP1.1 UDP协议端格式1.2 如何理解封装过程1.3 收到数据如何读取报文到应用层1.4 其它关于UDP二、守护进程化2.1 进程组、作业、会话概念2.2 网络服务守护进程化2.3 其它问题 - 地址复用个人主页矢望个人专栏C、Linux系统编程、Linux网络编程、C语言、数据结构、Coze-AI、MySQL再谈端口号端口号port用于标识主机上通信的唯一一个进程。0 - 1023知名端口号HTTP、FTP、SSH等这些广为使用的应用层协议它们的端口号都是固定的。1024 - 65535操作系统动态分配的端口号。客户端程序的端口号就是由操作系统从这个范围分配的。两个问题1. 一个进程是否可以 bind 多个端口号2. 一个端口号是否可以被多个进程bind首先第一个问题是正确的我们之前在进行网络通信时都是只创建了一个socket服务端只有一个通信端点对应绑定一个端口号如果创建多个socket自然就能绑定多个端口号。因此更严谨的定义是端口号用于标识主机上某个通信进程服务所使用的网络通信端点与IP地址共同构成socket定位主机上的一个通信端点socket服务。第二个问题不正确多个进程不能绑定同一端口号。一、传输层协议UDP1.1 UDP协议端格式我们之前在网络编程的时候看到端口号是16位的看上图你就会知道因为是内核中的UDP协议规定的。在收到UDP报文后有两个问题第一个报头和有效载荷如何分离第二个有效载荷分用的问题。如上图第一个问题UDP报头是固定8字节长度的所以提取前8个字节就分离出了报头然后报头中有一个16位UDP长度信息它表示了UDP报文的有效载荷有多长所以此时有效载荷也分离出来了。第二个问题UDP的报头中有一个16位目的端口号根据这个目的端口号就知道应该把有效载荷交给上层的那里。因此UDP并不存在数据报粘包问题它通过提取前八字节然后读取16位UDP长度信息就可以提取出完整的数据报。如何理解UDP协议报头呢不要忘记了UDP协议是传输层协议而传输层是被写入到了操作系统中的操作系统一般是C语言写的所以UDP协议报头其实是协议结构体通信双方都认识这个结构体所以双方可以互传结构体变量linux-2.6.18/include/linux/udp.hstructudphdr{__u16 source;// 源端口号__u16 dest;// 目的端口号__u16 len;// UDP长度__u16 check;// UDP校验和};1.2 如何理解封装过程操作系统可能会收到很多个报文操作系统内部就会同时存在多个报文。于是系统就需要对它做管理描述报文的结构体叫做sk_buff内核就通过这个结构体来描述收到的报文同时报文会存储在内核提供的缓冲区中通过sk_buff来管理这段缓冲区。封装的过程相关的某一层会收到上层发送过来的报文你可以理解内核缓冲区有一个起始指针p。由应用层向下传递时将这个p指针向上移动sizeof(应用层数据)这样就得到了一段开辟出来的空间然后把应用层数据拷贝到缓冲区中再到传输层这个p指针就会向上移动sizeof(传输层协议报头)的长度然后把传输层协议头拷贝进来以此类推。如上开辟好空间之后就可以强转成为协议结构体指针类型这样就可以拷贝数据了。在sk_buff结构体里就存在各协议的指针类型structsk_buff{// ...union{structtcphdr*th;structudphdr*uh;structicmphdr*icmph;structigmphdr*igmph;structiphdr*ipiph;structipv6hdr*ipv6h;unsignedchar*raw;}h;// ...};所以可以直接进行类型强转填写各层的协议层报头数据。上面所说的相关的移动p指针sk_buff中也是存在的只不过是4个指针structsk_buff{// ...unsignedchar*head,*data,*tail,*end;};如上图上图的左边是sk_buff结构体上图的右边就是内核缓冲区。end指向缓冲区尾部head指向缓冲区头部data和tail所对应的中间的空间就是报文一开始在应用层向下传递的时候data是等于tail的data在要放数据的时候会首先开辟出相关的空间大小比如在应用层就开辟出应用层数据的大小然后把应用层数据拷贝进来而向下传递到传输层时就开辟出传输层协议结构体的大小然后把传输层的协议报头信息拷贝进来。插入协议报头的过程就是入栈的过程。所以报文贯穿协议栈封装和解包的过程最核心的就是移动指针1.3 收到数据如何读取报文到应用层当我们收到一个报文的时候是如何做到使用文件原理读取数据到应用层的呢如上创建socket之后会返回一个文件描述符fdfd指向struct file结构体这个结构体中存在一个private_data的指针它又指向了一个struct socket的结构体这个结构体中还存在一个struct sock*指针。struct sock可以理解为基类结构体这个结构体内部存在接受缓冲区和发送缓冲区。包含struct sock结构体的结构体有inet_sock结构体它内部的第一个成员就是struct sock类型所以inet_sock可以访问接受缓冲区和发送缓冲区。而我们的主角udp_sock内部的第一个成员是struct inet_sock类型所以udp_sock也可以访问接受缓冲区和发送缓冲区因此就可以做到使用文件原理将报文读取上来了1.4 其它关于UDPUDP的特点UDP传输的过程类似于寄信。无连接知道对端的IP和端口号就直接进行传输不需要建立连接不可靠没有确认机制没有重传机制如果因为网络故障该段无法发到对方UDP协议层也不会给应用层返回任何错误信息面向数据报不能够灵活的控制读写数据的次数和数量。面向数据报应用层交给UDP多长的报文UDP原样发送既不会拆分也不会合并例如用UDP传输100个字节的数据如果发送端调用一次sendto发送100个字节那么接收端也必须调用对应的一次recvfrom接收100个字节而不能循环调用10次recvfrom每次接收10个字节UDP的缓冲区UDP没有真正意义上的发送缓冲区。调用sendto会直接交给内核由内核将数据传给网络层协议进行后续的传输动作UDP具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致如果缓冲区满了再到达的UDP数据就会被丢弃UDP使用注意事项UDP协议首部中有一个16位的最大长度。也就是说一个UDP能传输的数据最大长度是64K包含UDP首部。然而64K在当今的互联网环境下是一个非常小的数字。如果我们需要传输的数据超过64K就需要在应用层手动的分包多次发送并在接收端手动拼装基于UDP的应用层协议NFS网络文件系统TFTP简单文件传输协议DHCP动态主机配置协议BOOTP启动协议用于无盘设备启动DNS域名解析协议二、守护进程化2.1 进程组、作业、会话概念每一个进程除了有一个进程IDPID之外还属于一个进程组。进程组是一个或者多个进程的集合一个进程组可以包含多个进程。每一个进程组也有一个唯一的进程组IDPGID并且这个PGID类似于进程ID同样是一个正整数可以存放在pid_t数据类型中。如上图我启动了三个sleep进程查询发现它们属于同一个进程组它们的PGID都一样。一个进程组内部可以有一个或多个进程发送Ctrlc信号是向整个前台进程组发送的一发送整个进程组都会终止。并且每一个进程组都有一个组长进程。组长进程的进程ID等于该进程组的PGID。进程组组长的作用进程组组长可以创建一个进程组或者创建该组中的进程。进程组的生命周期从进程组创建开始到其中最后一个进程离开为止。注意只要某个进程组中有一个进程存在则该进程组就存在这与组长进程是否已经终止无关。当你将一个进程组设置为后台进程时你会发现一个[XX]上图表示的就是作业号。作业和进程组几乎是同一个东西它是shell做任务管理时的叫法作业要由进程组来完成。你可以通过jobs命令查询作业如上查询到了作业。作业有两个状态前台fg、后台bg。fg 作业号可以把后台进程组提到前台而bg 作业号可以让挂起的进程组在后台继续跑。操作流程CtrlZ→ 发SIGTSTP给前台作业把它挂起暂停bg→ 发SIGCONT让挂起的作业在后台继续跑fg→ 发SIGCONT把作业带回前台jobs查看作业列表%1、%2按作业号引用。小结作业 进程组作业是站在shell用户视角看一个任务进程组是内核视角看一组进程。会话可以看成是一个或多个进程组的集合一个会话可以包含多个进程组。每一个会话也有一个会话IDSID。如上它们这些进程组的SID都相同SID(Session Id)也就是会话ID。登录的过程就是一个构建会话的过程如上使用Xshell登录的时候用户输入账号密码信息发起请求服务端认证通过后就会构建会话创建bash进程并且创建终端文件。之后这个会话内部可以启动很多个进程组。会话ID和会话内的第一个进程的ID相同在上图会话ID就是bash进程的ID。注意上面的会话、bash进程、终端文件是三件事当你登录时系统就给你新建一个会话而创建bash进程、中断文件的事是Linux的sshd服务帮你做的。当我们启动我们之前写的网络服务./HttpServer时其实就是在当前会话内部启动作业可是当你注销关闭Xshell时这个会话就关闭了那么会话内部的进程组或作业就会受影响可能就被关闭了可能变成孤儿进程等等。反正我们的网络服务一定会受到用户登录或者注销的影响但网络服务不应该受到登录和注销的影响啊那么如何不受影响呢很简单自成会话如上让我们的网络服务自成会话就好了就不会受到登录和注销的影响了。至于如何成为一个独立的会话就是一个系统问题了在Linux上有一个系统调用setsid。setsid系统调用用于创建一个新的会话session并让调用进程成为该会话的领头进程session leader同时脱离原控制终端常用于创建守护进程daemon。它可以用于创建一个新的会话但是有一个要求如上图调用者不是进程组的组长时就能够创建一个新的会话。那么如何保证自己不是组长呢最先起来的就是进程组的组长所以可以通过fork创建子进程让子进程调用setsid替父进程完成工作父进程退出即可。所以守护进程也是一种孤儿进程。守护进程也叫做精灵进程daemon。上面讲进程组的时候说过只要进程组中存在进程进程组就不会消失。所以进程组组长退出进程组还存在此时子进程就不是进程组的组长了。2.2 网络服务守护进程化接下来就对我们上期博客写过的网络服务进行守护进程化。将来守护进程化的工作就在一个函数内部完成了例如Daemon进入这个函数时是父进程进入而函数调用完成之后出来的就是子进程了守护进程完成。守护进程要干的第一件事就是忽略信号可能自己的服务不希望有一些信号来打断。然后就是fork创建子进程建立新会话等工作。Daemon.hpp:voidDaemon(){// 1、忽略信号signal(SIGPIPE,SIG_IGN);signal(SIGCHLD,SIG_IGN);// 2、确保不是进程组长if(fork()0)exit(0);// 父进程退出// 3、设置新会话 -- 子进程pid_t idsetsid();(void)id;}如上这时候守护进程化的工作基本完成了新会话已经创建了。不过还有一些问题我们的网络服务在运行的时候打印了很多日志信息网络服务守护进程之后就变成了后台进程所以需要把日志打印到日志文件中。同时服务器的代码中可能还会存在很多的调试信息比如printf、cout、cerr等这些信息在守护进程之后就不能打印了但是为了保住调试信息可以把它们打印的消息重定向到/dev/null中这个文件Linux上是存在的。/dev/null是一个特殊的设备文件它接收到的所有数据都会被系统直接丢弃而读取它会立即返回文件结束符EOF。所以接下来还需要重定向012到/dev/null文件下。// 5、重定向012intfdopen(/dev/null,O_RDWR);// 以读写方式打开if(fd0){dup2(fd,0);dup2(fd,1);dup2(fd,2);close(fd);}部署问题现在工作基本完成不过在启动网络服务守护进程化之前还有一个部署问题网络服务一般由很多个模块构成比如日志模块网络服务模块、资源模块、配置文件模块等这些模块一般是要拷贝部署到Linux下的不同目录下的。我们的应用程序一般要有一个起始路径一般喜欢把应用程序部署到根目录这样方便程序迅速找到所有其它的相关文件或者资源可以通过绝对路径的方式访问任意文件。所以在Deamon内部可以把程序的工作路径改成/根目录到时候部署服务时将资源等部署到根目录下的特定目录下。// 守护进程voidDaemon(){// 1、忽略信号signal(SIGPIPE,SIG_IGN);signal(SIGCHLD,SIG_IGN);// 2、确保不是进程组长if(fork()0)exit(0);// 父进程退出// 3、设置新会话 -- 子进程pid_t idsetsid();(void)id;// 4、更改守护进程的工作路径chdir(/);// 5、重定向012intfdopen(/dev/null,O_RDWR);// 以读写方式打开if(fd0){dup2(fd,0);dup2(fd,1);dup2(fd,2);close(fd);}}将工作路径更改到/根目录之后到时候程序就工作在根目录。接口完善之后接下来就是在主函数中调用它。intmain(intargc,char*argv[]){SET_FILE_LOG_STRATEGY();// 使用日志模块的文件打印策略if(argc!2){Usage(argv[0]);exit(2);}uint16_tserver_portstd::stoi(argv[1]);// 守护进程化Daemon();// ...return0;}部署工作将网络服务程序添加到命令池中由于程序工作在根目录我们的程序的逻辑是在当前工作路径找资源所以也把相关的资源部署到/根目录下。启动网络程序测试如上在根目录下成功启动了网络程序由于要在/目录下创建日志文件所以加上了sudo。并且它自成会话TTY是?表示不属于任何一个bash会话不再有控制终端(TTY)。使用浏览器访问如上服务可以正常访问并且再查看日志信息浏览器访问产生的日志信息被保存到了日志文件当中。再来看fd0, 1, 2如上它们被重定向到了/dev/null文件。更重要的是现在关掉Xshell终端也是可以使用浏览器访问的这时候网络服务就部署到了云服务器上自成会话其实我们写的Daemon函数里面的是否更改工作路径与是否重定向的工作是可以选择的所以在函数参数中可以添加两个参数表示是否要更改工作路径以及是否要重定向。// 1, 1: 更改工作路径重定向voidDaemon(intisdir,intisdup){// 1、忽略信号signal(SIGPIPE,SIG_IGN);signal(SIGCHLD,SIG_IGN);// 2、确保不是进程组长if(fork()0)exit(0);// 父进程退出// 3、设置新会话 -- 子进程pid_t idsetsid();(void)id;// 4、更改守护进程的工作路径if(isdir)chdir(/);// 5、重定向012if(isdup){intfdopen(/dev/null,O_RDWR);// 以读写方式打开if(fd0){dup2(fd,0);dup2(fd,1);dup2(fd,2);close(fd);}}}如上如果希望进行更改路径和重定向就调用Daemon(1, 1);就可以了。为什么突然要说这个呢因为Linux上还有一个系统调用就是daemon只不过它的参数名和我的Daemon函数参数名相反它是daemon(0, 0);表示更改工作路径并进行重定向。所以之后进行守护进程的工作就可以使用daemon函数进行了。intmain(intargc,char*argv[]){SET_FILE_LOG_STRATEGY();// 使用日志模块的文件打印策略if(argc!2){Usage(argv[0]);exit(2);}uint16_tserver_portstd::stoi(argv[1]);// 守护进程化// Daemon(1, 1);daemon(0,0);// 系统调用// ...return0;}再次进行测试如上也是自成会话和我们写的效果一样。只不过daemon接口内部可能没有做忽略信号的事情。2.3 其它问题 - 地址复用如上之前在进行客户端与服务端通信的时候存在一个问题当客户端与服务端建立tcp连接后当服务端先退出时服务端再次使用之前的端口号再次立即启动就启动不起来了。但是服务端的端口号是不能随便改的所以就需要解决这个问题。这个问题产生的原因是TCP主动关闭方在关闭连接后会进入持续一段时间的TIME_WAIT状态导致其使用的端口在该状态期间被系统内核视为“仍在使用中”从而阻止同一端口的立即重新绑定。所以要解决这个问题就是重新使用这个端口。解决这个问题只需要在创建socket时在把地址复用功能打开也就是调用setsockopt函数。setsockopt()是一个系统调用用于设置套接字socket的各种行为选项从而精细地控制网络通信的底层细节比如允许端口重用、调整收发缓冲区大小、设置超时时间等。参数类型作用常见取值示例sockfdint要设置选项的套接字描述符由socket()返回sockfdlevelint指定选项所在的协议层SOL_SOCKET通用套接字层、IPPROTO_TCPTCP层、IPPROTO_IPIP层optnameint指定要设置的具体选项名称SO_REUSEADDR端口重用、SO_RCVBUF接收缓冲区大小、TCP_NODELAY禁用Nagle算法optvalconst void*指向选项值的指针存放要设置的新值通常指向一个int型变量如int opt 1;optlensocklen_toptval指向数据的大小以字节为单位sizeof(opt)快速记忆setsockopt(套接字, 协议层, 选项名, 值, 值大小)因此修改Socket.hpp的创建socket接口voidCreateSocketOrDie()override{_sockfdsocket(AF_INET,SOCK_STREAM,0);if(_sockfd0){LOG(LogLevel::FATAL)create socket error!;exit(SOCKET_ERR);}// 端口复用intopt1;setsockopt(_sockfd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));}再次测试总结以上就是本期博客分享的全部内容啦如果觉得文章还不错的话可以三连支持一下你的支持就是我前进最大的动力技术的探索永无止境! 道阻且长行则将至后续我会给大家带来更多优质博客内容欢迎关注我的CSDN账号我们一同成长(▽)