赞
踩
在上一篇博客中,我们学习了Udp协议的相关内容,今天我们开始学习Tcp协议相关的本内容,并带着大家完成相关的代码的编写。
为了更好的学习Tcp协议的内容,我们就要对Tcp协议的特点有一个全面的了解。
以下是一个以表格形式呈现的UDP和TCP协议的异同点:
特性 | UDP(用户数据报协议) | TCP(传输控制协议) |
---|---|---|
可靠性 | 不保证数据的可靠性,不保证数据顺序或到达 | 保证数据的可靠性,通过确认和重传机制确保数据正确无误地到达目的地 |
连接性 | 无连接协议,发送数据前不需要建立连接 | 面向连接的协议,在发送数据前需要建立连接(三次握手),数据传输完毕后需要断开连接(四次挥手) |
头部开销 | 头部较小(通常8字节),传输效率高 | 头部较大(至少20字节,可能更多),包含更多的控制信息 |
数据传输方式 | 数据以数据报的形式传输,每个数据报独立处理 | 数据以字节流的形式传输,确保数据的连续性和完整性 |
拥塞控制 | 不进行拥塞控制,网络拥塞时可能导致数据丢失 | 通过滑动窗口和拥塞控制算法(如慢启动、拥塞避免、快重传、快恢复)来避免网络拥塞 |
实时性 | 实时性较好,适用于对实时性要求较高的应用(如视频流、实时游戏) | 实时性较差,但可靠性高,适用于对可靠性要求较高的应用(如文件传输、电子邮件) |
适用场景 | 视频会议、流媒体、DNS查询、实时游戏等 | 文件传输(FTP)、网页浏览(HTTP)、电子邮件(SMTP)等 |
流量控制 | 不进行流量控制,发送方以恒定速率发送数据,不考虑接收方的接收能力 | 通过滑动窗口机制进行流量控制,确保发送方的发送速率不超过接收方的接收能力 |
错误处理 | 如果数据报在传输过程中出错,则丢弃该数据报,由上层协议负责错误处理 | 通过确认和重传机制来处理错误,确保数据的正确传输 |
在现阶段,我们要关注的是:使用Tcp协议在通信的前提是客户端和服务器之间要建立链接。具体等到写代码时会详细的说明。
在Udp协议时,我们学习了几个网络方面常用的接口,今天我们需要再认识几个:
listen
listen函数是在socket编程中广泛使用的一个函数,特别是在TCP服务器端编程中。它的主要作用是将一个套接字(socket)设置为监听状态,以便能够接受来自客户端的连接请求。以下是listen函数的详细介绍:
一、函数原型
listen函数的原型定义在<sys/socket.h>
头文件中,其原型如下:
#include <sys/socket.h>
int listen(int sockfd, int backlog);
二、参数说明
三、函数功能
listen函数的作用是将sockfd指定的套接字设置为监听状态,使其能够接受来自客户端的连接请求。当客户端发起连接请求时,内核会将请求放入请求队列中,然后等待服务器端的accept函数来接受这些连接。backlog参数限制了请求队列的最大长度,防止了服务器因接收过多连接请求而耗尽资源。
四、返回值
五、使用场景
在TCP服务器端编程中,listen函数通常紧随bind函数之后调用。bind函数用于将套接字绑定到特定的IP地址和端口上,而listen函数则将该套接字设置为监听状态,准备接受客户端的连接请求。之后,服务器可以调用accept函数来接受客户端的连接请求,并处理后续的数据传输。
六、注意事项
综上所述,listen函数是TCP服务器端编程中不可或缺的一部分,它使得服务器能够同时处理多个客户端的连接请求。
connect函数是网络编程中常用的一个函数,主要用于建立客户端与服务器之间的连接。以下是connect函数的详细介绍:
connect
一、函数原型
connect函数的原型定义在<sys/socket.h>
头文件中,其原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
二、参数说明
sockaddr
结构体的指针,该结构体包含了要连接的服务器的地址信息,包括IP地址和端口号。在实际使用中,通常会使用sockaddr_in
结构体(对于IPv4)或sockaddr_in6
结构体(对于IPv6)来提供这些信息,并在调用connect函数前将其地址强制转换为sockaddr *
类型。sizeof(struct sockaddr_in)
或sizeof(struct sockaddr_in6)
来获取。三、函数功能
connect函数用于客户端发起对服务器的连接请求。当客户端调用connect函数时,它会尝试与由addr参数指定的服务器地址和端口建立TCP连接。如果连接成功,connect函数返回0;如果连接失败,则返回-1,并设置errno以指示错误原因。
四、使用场景
connect函数主要在客户端编程中使用,用于与服务器建立连接。在客户端发起连接请求之前,通常需要先调用socket函数创建一个套接字,并调用bind函数(虽然对于客户端来说,bind函数是可选的,但在某些特定场景下可能需要使用)和listen函数(仅在服务器端使用)来准备套接字。然后,客户端就可以使用connect函数来尝试与服务器建立连接了。
五、注意事项
accpet
accept函数是网络编程中常用的一个函数,特别是在TCP服务器端编程中。它的主要作用是使服务器端接受客户端的连接请求,并在连接建立后返回一个用于后续通信的新的套接字文件描述符。以下是accept函数的详细介绍:
一、函数原型
accept函数的原型定义在<sys/socket.h>
头文件中,其原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
二、参数说明
sockaddr
结构体的指针,用于存储接受到的客户端的地址信息(包括IP地址和端口号)。这个参数是可选的,如果不需要获取客户端的地址信息,可以将其设置为NULL。socklen_t
类型的变量的指针,用于存储addr
结构体的大小。在调用accept函数之前,应该将其初始化为addr
结构体的大小(如sizeof(struct sockaddr_in)
),函数返回时,它会被设置为实际返回的地址信息的长度。三、函数功能
accept函数的作用是从sockfd指定的监听套接字的等待连接队列中抽取第一个连接请求,创建一个新的套接字,并将这个新套接字的文件描述符返回给调用者。这个新套接字用于与客户端进行后续的数据通信,而原始的监听套接字(sockfd)则继续保持在监听状态,等待接受其他客户端的连接请求。
四、返回值
五、使用场景
accept函数主要在TCP服务器端编程中使用,用于接受客户端的连接请求。在服务器端调用listen函数将套接字设置为监听状态后,就可以通过循环调用accept函数来接受多个客户端的连接请求,并为每个连接请求创建一个新的套接字进行通信。
六、注意事项
首先,服务器的完整代码如下:
#pragma once #include <iostream> #include <string> #include <cstring> #include <cstdlib> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/wait.h> #include <signal.h> #include <pthread.h> #include "log.hpp" #include "Task.hpp" #include "ThreadPool.hpp" using namespace std; namespace server { enum { USAGE_ERR = 1, SOCKET_ERR, BIND_ERR, LISTEN_ERR }; static const uint16_t gport = 8080; static const int gbacklog = 5; class TcpServer { public: TcpServer(const uint16_t &port = gport) : _listensock(-1), _port(port) { } void initServer() { // 1. 创建socket文件套接字对象 _listensock = socket(AF_INET, SOCK_STREAM, 0); if (_listensock < 0) { cerr << "socket error: " << errno << strerror(errno) << endl; exit(SOCKET_ERR); } cout << "socket success" << endl; // 2. bind绑定自己的网络信息 struct sockaddr_in local; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(_port); local.sin_addr.s_addr = INADDR_ANY; if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0) { cerr << "bind error: " << errno << strerror(errno) << endl; exit(BIND_ERR); } cout << "bind success" << endl; // 3. 设置socket 为监听状态 if (listen(_listensock, gbacklog) < 0) // 第二个参数backlog后面在填这个坑 { cerr << "bind error: " << errno << strerror(errno) << endl; exit(LISTEN_ERR); } cout << "bind success" << endl; } void start() { for (;;) { // 4. server 获取新链接 // sock, 和client进行通信的fd struct sockaddr_in peer; socklen_t len = sizeof(peer); int sock = accept(_listensock, (struct sockaddr *)&peer, &len); if (sock < 0) { continue; } serverIO(sock); close(sock); } } // 这个函数负责通信的过程。 void serverIO(int sock) { char buffer[1024]; while (1) { ssize_t s = read(sock, buffer, sizeof(buffer) - 1); if (s > 0) { buffer[s] = 0; cout << buffer << endl; } else { cout << "client end,me too" << endl; close(sock); break; } } } ~TcpServer() {} private: int _listensock; // 不是用来进行数据通信的,它是用来监听链接到来,获取新链接的! uint16_t _port; }; } // namespace server
代码说明:
由于Tcp面向字节流这一特征,使得我们可以像
我们创建完套接字并绑定后,必须使得客户端处于listen状态,原因有:
准备接收连接:listen
函数的主要作用是将套接字(socket)从主动连接状态转变为被动监听状态。这意味着服务器端的套接字不再主动发起连接,而是等待客户端的连接请求。没有调用listen
函数之前,套接字默认是处于主动连接状态,即用于客户端发起连接请求的。
设置监听队列:listen
函数允许你指定操作系统内核为相应套接字排队的最大连接数(backlog)。这个队列保存了那些已经与服务器建立了同步(即完成了TCP三次握手的前两步),但尚未被服务器accept
函数处理的客户端连接。设置合理的backlog值对于服务器在高负载下的性能表现至关重要。
状态转换:从TCP/IP协议的角度来看,调用listen
函数是TCP服务器状态转换的一部分。在TCP连接建立的过程中,服务器端套接字需要经历从CLOSED
到LISTEN
的转换,才能开始接受客户端的连接请求。
协议要求:TCP/IP协议规定,在服务器能够accept
客户端的连接之前,必须先调用listen
函数将套接字置于监听状态。这是一种协议级别的要求,确保了TCP连接的建立过程能够有序、可预测地进行。
错误检测:listen
函数的调用还可以帮助开发者在早期发现潜在的配置错误或资源限制问题。例如,如果尝试在一个非套接字文件描述符上调用listen
,或者指定的backlog值过大导致系统资源不足,listen
函数将返回错误。
调用listen
函数是TCP服务器实现过程中不可或缺的一步,它确保了服务器能够正确地准备并接受来自客户端的连接请求。
接受
来着客户端给我们发出的链接申请了。这个工作由accept完成。
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
来看:
第一个参数为创建套接字的文件描述符。返回值也是一个文件描述符。这两个文件描述符是什么关系呢?
接下来,我给大家讲一个故事,大家听完这个故事就明白了。
一天,张三和李四来到了一个小镇,镇上有好多饭馆。他们决定挑一家店尝尝口味怎么样。他们来到一家饭馆门前,这时店小二对他们热情的说:“二位客观要吃饭吗?我们家是百年老店,味道绝对正宗”。然后他们两个就往店里走,店小二一边招呼着他们两个,一边说:“客人两位”。然后店小二又回到店门口继续拉客了,其他伙计负责点餐。
此时的代码是由问题的,因为这个服务端一次只能和一台客户端进行通信。这个问题我们一会儿想办法解决。
完整的代码如下:
#pragma once #include <iostream> #include <string> #include <cstring> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #define NUM 1024 class TcpClient { public: TcpClient(const std::string &serverip, const uint16_t &serverport) : _sock(-1), _serverip(serverip), _serverport(serverport) {} void initClient() { // 1. 创建socket _sock = socket(AF_INET, SOCK_STREAM, 0); if(_sock < 0) { std::cerr << "socket create error" << std::endl; exit(2); } // 2. tcp的客户端要不要bind?要的! 要不要显示的bind?不要!这里尤其是client port要让OS自定随机指定! // 3. 要不要listen?不要! // 4. 要不要accept? 不要! // 5. 要什么呢??要发起链接! } void start() { struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(_serverport); server.sin_addr.s_addr = inet_addr(_serverip.c_str()); if(connect(_sock, (struct sockaddr*)&server, sizeof(server)) != 0) { std::cerr << "socket connect error" << std::endl; } else { std::string msg; while(true) { std::cout << "Enter# "; std::getline(std::cin, msg); write(_sock, msg.c_str(), msg.size()); char buffer[NUM]; int n = read(_sock, buffer, sizeof(buffer)-1); if(n > 0) { //目前我们把读到的数据当成字符串, 截止目前 buffer[n] = 0; std::cout << "Server回显# " << buffer << std::endl; } else { break; } } } } ~TcpClient() { if(_sock >= 0) close(_sock); } private: int _sock; std::string _serverip; uint16_t _serverport; };
如上图:上面的代码是串行执行的。所以当服务器在执行serverIO函数时,就不会执行accpet函数。
也就意味着:服务端在同一时间只能和一个客户端进行通信。这是不合理的。如何解决呢?
void start() { for (;;) { // 4. server 获取新链接 // sock, 和client进行通信的fd struct sockaddr_in peer; socklen_t len = sizeof(peer); int sock = accept(_listensock, (struct sockaddr *)&peer, &len); if (sock < 0) { continue; } pid id=fork(); if(id==0)//child { close(_listensock); if(fork()>0)exit(0) serverIO(sock); close(sock); } // parent pid_t t=waitpid(id,nullptr,0); if(t>0) { cout<<"waitpid success"<<endl; } } }
我们来分析一下如此设计的妙处:我们让子进程来执行通信任务,父进程阻塞式等待。但是这样做,整个代码还是串行执行的,和起初的代码没有区别。所以我们可以让子进程的子进程来执行通信任务,子进程返回,然后父进程马上waitpid成功开始执行accpet函数。此时的孙子进程就变成了一个孤儿进程,由操作系统领养,和主进程没有关系。
我们在学习操作系统信号部分时学到:子进程退出时会向父进程发送信号。如果我们将该信号自定义为忽略,父进程就不需要等待子进程退出了。所以代码我们可以这样设计:
void start() { signal(SIGCHLD,SIG_IGN); for (;;) { // 4. server 获取新链接 // sock, 和client进行通信的fd struct sockaddr_in peer; socklen_t len = sizeof(peer); int sock = accept(_listensock, (struct sockaddr *)&peer, &len); if (sock < 0) { continue; } pid id=fork(); if(id==0)//child { close(_listensock); // if( fork()>0)exit(0) serverIO(sock); close(sock); } // parent // pid_t t=waitpid(id,nullptr,0); // if(t>0) // { // cout<<"waitpid success"<<endl; // } } }
如图,这种方式比较简单,我们在学习线程时写过大量的这种代码,又因为改动的代码较多,所以这里我就不再对代码进行修改了。如上图大家自行对服务器代码进行修改。
如果我们在Linux服务器上跑着一个服务,突然我们的xshell异常终止了,这个服务也就被关闭了,这显然是不合理的。所以我们就需要将该服务守护进程化。
守护进程也就是通常说的 Daemon 进程(精灵进程),是 Linux 中的后台服务进程。它是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
守护进程是个特殊的孤儿进程,这种进程脱离终端,为什么要脱离终端呢?之所以脱离于终端是为了避免进程被任何终端所产生的信息所打断,其在执行过程中的信息也不在任何终端上显示。由于在 Linux 中,每一个系统与用户进行交流的界面称为终端,每一个从此终端开始运行的进程都会依附于这个终端,这个终端就称为这些进程的控制终端,当控制终端被关闭时,相应的进程都会自动关闭。
Linux 的大多数服务器就是用守护进程实现的。比如,Internet 服务器 inetd,Web 服务器 httpd 等。
如上图:就我现在用的Linux而言。我用的是从腾讯云租的服务器,我使用时用xsehll登上我的服务器即可。
登入服务器过后,服务器会在远端给我创建一个会话,会话中包括提供命令行解释的bash和若干个进程。这些进程可以有多个后台进程,但有且只能有一个前台进程。这个会话在关闭xshell后自动就丢失了。
如果在一个进程后面加上&
符号,该进程后自动在后台运行。
如上图:一个进程组共同完成一个作业。同属于一个进程组的进程的PGID是相同的,它们的PID差一的。其中第一个进程为作业组长。
此外,我们也需要认识几个命令:
jobs
:查询所有的系统中作业。bg+作业号
:把一个后台作业放到前台运行。fg+作业号
:使一个后台暂停的作业重新启动。总结一下:我们如上讲的作业和进程组是受用户登录和注销影响的。如果我们想让进程不受用户登录和注销的影响(也就是实现守护进程化)。我们需要自称会话、自称进程组和终端设备。
实现方案很多。
方案1
在系统中有相关的函数,我们可以通过相关的函数来实现守护进程。如下:
daemon函数是Linux系统中用于创建守护进程(Daemon Process)的一个函数。守护进程是一种在后台运行的特殊进程,它独立于控制终端,并且周期性地执行某种任务或等待处理某些发生的事件。下面是对daemon函数的详细介绍:
一、函数原型
#include <unistd.h>
int daemon(int nochdir, int noclose);
二、参数说明
nochdir
:如果此参数为0,则daemon函数会将进程的当前工作目录更改为根目录(“/”)。这有助于守护进程与文件系统挂载点等环境隔离开来。noclose
:如果此参数为0,则daemon函数会将标准输入(stdin)、标准输出(stdout)和标准错误输出(stderr)重定向到/dev/null
。这意味着守护进程不会接收任何输入,其输出和错误也不会显示在终端或任何文件中。三、返回值
四、实现原理
daemon函数的实现通常涉及以下几个步骤:
fork()
函数创建一个新的子进程。父进程随后退出,留下子进程继续执行。这样做是为了让守护进程与父进程(通常是shell或启动脚本)的环境隔离开来。setsid()
函数创建一个新的会话(session),并使该子进程成为会话的领头进程(session leader)。这会使得该进程完全与控制终端脱离。nochdir
参数为0,则将当前工作目录更改为根目录(“/”)。noclose
参数为0,则将标准输入、标准输出和标准错误输出重定向到/dev/null
。五、主要用途
daemon函数主要用于需要长时间在后台运行的服务程序,如Web服务器、数据库服务器、作业调度器等。通过创建守护进程,这些服务可以在用户注销或终端关闭后继续运行,而不会受到终端会话结束的影响。
六、注意事项
综上所述,daemon函数是Linux系统中用于创建守护进程的一个重要工具,它能够帮助程序在后台稳定运行,并处理各种系统任务。
但是,这个函数中规定了很多的内容。不如我们手写一个小组件使用起来方便。
方案2
在Linux文件中,有这样一个文件:
它的功能描述起来就是:将所有写进该文件的所有内容全部丢弃。但是在从文件中读取内容时不会阻塞也不会出错。
将标准输入(stdin)、标准输出(stdout)和标准错误输出(stderr)重定向到/dev/null。这意味着不会接收任何输入,其输出和错误也不会显示在终端或任何文件中。
setsid()
函数是 Unix 和 Unix-like 系统(如 Linux)中的一个系统调用,用于创建一个新的会话(session),并使调用进程成为该会话的领头进程(session leader)。同时,调用 setsid()
的进程会成为一个新的进程组的组长,并且脱离任何现有的控制终端(如果有的话)。
setsid()
函数的原型定义在 <unistd.h>
头文件中,其基本用法如下:
#include <unistd.h>
pid_t setsid(void);
如果调用成功,setsid()
返回调用进程的 PID(即新会话的领头进程的 PID)。如果调用失败,则返回 -1,并设置 errno 以指示错误原因。
以下是一些关于 setsid()
函数的要点:
新会话的创建:调用 setsid()
会创建一个新的会话,并且调用进程成为该会话的领头进程。这意味着该进程将不再属于之前的会话和进程组。
进程组的组长:新的会话领头进程也会成为其所在进程组的新组长。这是因为会话领头进程必须是一个进程组的组长。
脱离控制终端:如果调用 setsid()
的进程之前与控制终端相关联,那么调用成功后,该进程将不再与任何控制终端相关联。这是守护进程(daemon)的一个重要特性,因为守护进程通常需要在后台运行,并且不应该依赖于任何特定的终端。
使用场景:setsid()
通常用于创建守护进程,但也可以用于其他需要独立会话和进程组的场景。
限制:调用 setsid()
的进程不能是进程组的组长。如果已经是组长,则调用会失败并返回 -1。
所以,我们需要做好调用setsid函数的准备:
/dev/null
中。chdir
进行改变。所以,代码如下:
#pragma once #include <iostream> #include <unistd.h> #include <cassert> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include<signal.h> #define DEV "/dev/null" void my_daemon() { // 忽略信号 signal(SIGPIPE,SIG_IGN); pid_t id=fork(); if(id==0) { //重定向工作 int sock=open(DEV,O_RDWR); if(sock>=0) { dup2(sock,0); dup2(sock,1); dup2(sock,2); } else { close(0); close(2); close(1); } pid_t n=setsid(); assert(n!=-1); //更改一下目录 chdir("/"); } }
本文内容到此结束,我们下一篇博客再见!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。