【网络编程套接字】基于TCP协议的网络程序

上节我们实现了一个基于UDP协议的网络程序,本节,我们再来实现一个简单的TCP网络程序。

服务端

服务端创建套接字

socket函数

创建套接字的函数叫做socket,该函数的函数原型如下:

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

TCP服务器调用socket函数创建套接字时,参数设置如下:

  • 协议家族选择AF_INET,因为我们要进行的是网络通信。
  • 创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
  • 协议类型默认设置为0即可。
//1.创建套接字
    int listen_sock=socket(AF_INET,SOCK_STREAM,0);//SOCK_STREAM(流式套接)
    if(listen_sock<0)//套接字创建失败
    {
        cerr<<"socket error: "<<errno<<endl; 
        return 2;
    }

TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务。

服务端绑定

TCP服务器绑定时的步骤与UDP服务器是完全一样的,没有任何区别。

bind函数

绑定的函数叫做bind,该函数的函数原型如下:

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

参数说明:

  • sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
  • addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址,包括协议家族、IP地址、端口号等。
  • addrlen:传入的addr结构体的长度。

返回值说明:

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

绑定的步骤如下:

  • 定义一个struct sockaddr_in结构体,将服务器网络相关的属性信息填充到该结构体当中,比如协议家族、IP地址、端口号等。
  • 填充服务器网络相关的属性信息时,协议家族对应就是AF_INET,端口号就是当前TCP服务器程序的端口号。在设置端口号时,需要调用htons函数将端口号由主机序列转为网络序列。
  • 在设置服务器的IP地址时,我们可以设置为本地环回127.0.0.1,表示本地通信。也可以设置为公网IP地址,表示网络通信。
  • 我们使用的是云服务器,那么在设置服务器的IP地址时,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序的转换。

INADDR_ANY的思考

  • INADDR_ANY转换过来就是0.0.0.0,泛指本机的意思,也就是表示本机的所有IP,因为有些机子不止一块网卡,多网卡的情况下,这个就表示所有网卡ip地址的意思。
  • 比如一台电脑有3块网卡,分别连接三个网络,那么这台电脑就有3个ip地址,如果绑定某个具体的ip地址,你的端口只能通过接你所设置的ip地址所在的网卡接收数据,其它两块网卡无法进行接收,如果三个网卡都想接收,那就需要绑定3个ip,也就等于需要管理3个套接字进行数据交换,这样岂不是很繁琐?
  • 所以就出现了INADDR_ANY,你只需绑定INADDR_ANY,管理一个套接字就行,此时不管数据是从哪个网卡过来的,只要是你绑定的端口号过来的数据,都可以接收到。
  • 填充完服务器网络相关的属性信息后,需要调用bind函数进行绑定。绑定实际就是将文件与网络关联起来,如果绑定失败也没必要进行后续操作了,直接终止程序即可。需要注意,由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in强转为struct sockaddr类型后再进行传入。
//2.bind
    struct sockaddr_in local;
    memset(&local,0,sizeof(local));//对结构体变量进行清空
    //bzero(&local,sizeof(local));//作用和memset差不多
    local.sin_family=AF_INET;
    local.sin_port=htons(atoi(argv[1]));
    local.sin_addr.s_addr=INADDR_ANY;

    if(bind(listen_sock,(struct sockaddr*)&local,sizeof(local))<0)
    {
        cerr<<"bind error: "<<errno<<endl;
        return 3;
    }

定义好struct sockaddr_in结构体后,最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空(但是不建议使用)。bzero函数也可以对特定的一块内存区域进行清空,bzero函数的函数原型如下:

void bzero(void *s, size_t n);

服务端监听

UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,因此客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。

  • 一定有人主动建立(客户端,需要服务),一定有人被动接受连接(服务器,提供服务)

  • 我们当前写的是一个server,特点是周而复始的不间断的等待客户到来,所以我们要不断的给用户提供一个建立连接的功能,此时就需要将TCP服务器创建的套接字设置为监听状态

  • 设置套接字是Listen(监听)状态,本质是允许用户连接。

listen函数

设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:

int listen(int sockfd, int backlog);

参数说明:

  • sockfd:需要设置为监听状态的套接字对应的文件描述符。
  • backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。

返回值说明:

  • 监听成功返回0,监听失败返回-1,同时错误码会被设置。
const int back_log=5;
    if(listen(listen_sock,back_log)<0)
    {
        cerr<<"listen error"<<endl;
        return 4;
    }

创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成。


服务端获取连接

此时,TCP服务器的初始化已经完成,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。

accept函数(重点理解)

获取连接的函数叫做accept,该函数的函数原型如下:

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

参数说明:

  • sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。

返回值说明:

获取连接成功,返回接收到的套接字的文件描述符,获取连接失败,返回-1,同时错误码会被设置。


accept函数返回值的理解


如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。

  • 首先我们要明白,在服务器端,socket()返回的套接字用于监听(listen)和接受(accept)客户端的连接请求。这个套接字不能用于与客户端之间发送和接收数据。

  • accept()用于接受一个客户端的连接请求,如果获取连接成功,会返回一个新的套接字。所谓“新的”就是说这个套接字与socket()返回的用于监听和接受客户端的连接请求的套接字不是同一个套接字。本次接受的客户端的通信就是通过在这个新的套接字上发送和接收数据来完成的。

  • 再次调用accept()可以接受下一个客户端的连接请求,并再次返回一个新的套接字(与socket()返回的套接字、之前accept()返回的套接字都不同的新的套接字)。这个新的套接字用于与这次接受的客户端之间的通信。假设一共有2个客户端连接到服务器端。那么在服务器端就一共有3个套接字:第1个是socket()返回的、用于监听的套接字;其余2个是分别调用2次accept()返回的不同的套接字。

  • 如果已经有客户端连接到服务器端,不再需要监听和接受更多的客户端连接的时候,可以关闭由socket()返回的套接字,而不会影响与客户端之间的通信。当某个客户端断开连接、或者是与某个客户端的通信完成之后,服务器端需要关闭用于与该客户端通信的套接字。

也就是说,accept函数接受一个客户端请求后会返回一个新的SOCKFD值,当有不同的客户端同时有不同请求时,会返回不同的SOCKFD的值。这个不同的值和建立SOCKET 时生成的SOCKFD是不同的。服务器与客户端之间的通信就是在这些不同的SOCKFD上进行的。

获取等待连接队列请求的原则

  • 前面说到listen函数,它的参数 backlog代表全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列.
  • accept函数会从s的等待连接队列中抽取第一个连接,创建一个与s同类的新的套接口并返回句柄。如果队列中无等待连接,且套接口为阻塞方式,则accept()阻塞调用进程直至新的连接出现。如果套接口为非阻塞方式且队列中无等待连接,则accept()返回一错误代码。已接受连接的套接口不能用于接受新的连接,原套接口仍保持开放。
获取连接
for (;;)
    {
        struct sockaddr_in peer;
        socklen_t len=sizeof(peer);
        int new_sock=accept(listen_sock,(struct sockaddr*)&peer,&len);
        if(new_sock<0)
        {
            continue;
        }

        cout<<"get a new link ..."<<endl;

 }

accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接,所以我们用for循环。

服务端处理请求

现在TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理。此时为客户端提供服务的不是监听套接字,而是accept函数返回的套接字,下面就将其称为“服务套接字”。

为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了。

read函数

TCP服务器读取数据的函数叫做read,该函数的函数原型如下:

ssize_t read(int fd, void *buf, size_t count);

参数说明:

  • fd:特定的文件描述符,表示从该文件描述符中读取数据。
  • buf:数据的存储位置,表示将读取到的数据存储到该位置。
  • count:数据的个数,表示从该文件描述符中读取数据的字节数。

返回值说明:

  1. 如果返回值大于0,则表示本次实际读取到的字节个数。
  2. 如果返回值等于0,则表示对端已经把连接关闭了。
  3. 如果返回值小于0,则表示读取时遇到了错误。
write函数

TCP服务器写入数据的函数叫做write,该函数的函数原型如下:

ssize_t write(int fd, const void *buf, size_t count);

参数说明:

  • fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
  • buf:需要写入的数据。
  • count:需要写入数据的字节个数。

返回值说明:

  • 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。

当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。

需要注意的是,服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。

处理请求,提供服务:

//我们将获取连接请求成功后提供服务的操作封装成函数
void ServiceIO(int new_sock)
{
    //成功,提供服务 
        while(true)
        {
            char buffer[1024];
            memset(buffer,0,sizeof(buffer));
            ssize_t s=read(new_sock,buffer,sizeof(buffer)-1); 
            if(s>0)
            {
                buffer[s]=0;//将获取的内容当成字符串
                cout<<"client# "<<buffer<<endl;

                string echo_string=">>server<<,";
                echo_string +=buffer;

                write(new_sock,echo_string.c_str(),echo_string.size());
            }
            else if(s==0)//代表客户端把连接关了,所以读取返回为0
            {
                cout<<"client quit..."<<endl;
                break;
            }
            else{
                cerr<<"read error"<<endl;
                break;
            }
            close(sock); //归还文件描述符
        }
}

在从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。


此时服务端的部分就完成了。

服务端代码全览:

  • 注意,我们这里同样引入命令行参数
#include<iostream>
#include<sys/socket.h>
#include<sys/types.h>
#include<cstring>
#include<cerrno>
#include<string>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<unistd.h>
#include<signal.h>
#include<wait.h>
#include<pthread.h>
using namespace std;


void Usage(string proc)
{
    cout<<"Usage: "<<proc<<"port"<<endl;
}


void ServiceIO(int new_sock)
{
    //成功,提供服务 
        while(true)
        {
            char buffer[1024];
            memset(buffer,0,sizeof(buffer));
            ssize_t s=read(new_sock,buffer,sizeof(buffer)-1); 
            if(s>0)
            {
                buffer[s]=0;//将获取的内容当成字符串
                cout<<"client# "<<buffer<<endl;

                string echo_string=">>server<<,";
                echo_string +=buffer;

                write(new_sock,echo_string.c_str(),echo_string.size());
            }
            else if(s==0)//代表客户端把连接关了,所以读取返回为0
            {
                cout<<"client quit..."<<endl;
                break;
            }
            else{
                cerr<<"read error"<<endl;
                break;
            }
        }
}

//tcp server
int main(int argc,char *argv[])
{
    if(argc!=2)
    {
        Usage(argv[0]);
        return 1;
    }

    //1.创建套接字
    int listen_sock=socket(AF_INET,SOCK_STREAM,0);//SOCK_STREAM(流式套接)
    if(listen_sock<0)
    {
        cerr<<"socket error: "<<errno<<endl; 
        return 2;
    }
 
    //2.bind
    struct sockaddr_in local;
    memset(&local,0,sizeof(local));//对结构体变量进行清空
    local.sin_family=AF_INET;
    local.sin_port=htons(atoi(argv[1]));
    local.sin_addr.s_addr=INADDR_ANY;

    if(bind(listen_sock,(struct sockaddr*)&local,sizeof(local))<0)
    {
        cerr<<"bind error: "<<errno<<endl;
        return 3;
    }


    //设置套接字是Listen(监听)状态,本质是允许用户连接
    const int back_log=5;
    if(listen(listen_sock,back_log)<0)
    {
        cerr<<"listen error"<<endl;
        return 4;
    }
 
 	//获取连接
    for (;;)
    {
        struct sockaddr_in peer;
        socklen_t len=sizeof(peer);
        int new_sock=accept(listen_sock,(struct sockaddr*)&peer,&len);
        if(new_sock<0)
        {
            continue;
        }

        cout<<"get a new link ..."<<endl;

        ServiceIO(new_sock);//处理请求
    
    }
    return 0;
}

客户端

客户端创建套接字

客户端在调用socket函数创建套接字时,参数设置与服务端创建套接字时是一样的。

//1.创建socket
    int sock=socket(AF_INET,SOCK_STREAM,0);
    if(sock<0)
    {
        cerr<<"socket error!"<<endl;
        return 2;
    }

绑定问题

需要注意,客户端不需要进行绑定和监听。

但是客户端必须要知道它要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信。

void Usage(string proc)
{
    cout<<"Usage: "<<proc<<"server_ip server_port"<<endl;

}

int main(int argc,char*argv[])
{
    if(argc!=3)
    {
        Usage(argv[0]);
        return 1;
    }
    string svr_ip=argv[1];
    uint16_t svr_port=(uint16_t)atoi(argv[2]);

    //1.创建socket
    int sock=socket(AF_INET,SOCK_STREAM,0);
    if(sock<0)
    {
        cerr<<"socket error!"<<endl;
        return 2;
    }


    //client无需显示的bind
    //客户端无需 3.listen 4.accept
    //而是接下来需要直接connect
    struct sockaddr_in server;
    bzero(&server,sizeof(server));//作用和memset差不多,清空为0的作用
    server.sin_family=AF_INET;

    //下面的函数做两件事(inet_addr)
    //1.将点分十进制的字符串风格的IP,转换成四字节IP
    //2.将4字节由主机序列转化为网络序列
    server.sin_addr.s_addr=inet_addr(svr_ip.c_str());//server ip
    
    server.sin_port=htons(svr_port);//server port

客户端连接服务器

由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。

connect函数

发起连接请求的函数叫做connect,该函数的函数原型如下:

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

参数说明:

  • sockfd:特定的套接字,表示通过该套接字发起连接请求。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:传入的addr结构体的长度。

返回值说明:

连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。

//2.发起链接
    if(connect(sock,(struct sockaddr*)&server,sizeof(server))<0)
    {
        cout<<"connect server failed"<<endl;
        return 3;
    }

    cout<<"connect success!"<<endl;
    

客户端发起请求

当客户端连接到服务端后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务端,发送时调用write函数向套接字当中写入数据即可。

当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用read函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误。

#include<iostream>
#include<sys/socket.h>
#include<sys/types.h>
#include<cstring>
#include<cerrno>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<unistd.h>

using namespace std;

void Usage(string proc)
{
    cout<<"Usage: "<<proc<<"server_ip server_port"<<endl;

}

int main(int argc,char*argv[])
{
    if(argc!=3)
    {
        Usage(argv[0]);
        return 1;
    }
    string svr_ip=argv[1];
    uint16_t svr_port=(uint16_t)atoi(argv[2]);

    //1.创建socket
    int sock=socket(AF_INET,SOCK_STREAM,0);
    if(sock<0)
    {
        cerr<<"socket error!"<<endl;
        return 2;
    }


    //client无需显示的bind
    //客户端无需 3.listen 4.accept
    //而是接下来需要直接connect
    struct sockaddr_in server;
    bzero(&server,sizeof(server));//作用和memset差不多,清空为0的作用
    server.sin_family=AF_INET;

    //下面的函数做两件事(inet_addr)
    //1.将点分十进制的字符串风格的IP,转换成四字节IP
    //2.将4字节由主机序列转化为网络序列
    server.sin_addr.s_addr=inet_addr(svr_ip.c_str());//server ip
    
    
    server.sin_port=htons(svr_port);//server port

    //2.发起链接
    if(connect(sock,(struct sockaddr*)&server,sizeof(server))<0)
    {
        cout<<"connect server failed"<<endl;
        return 3;
    }

    cout<<"connect success!"<<endl;
    
    //进行正常的业务逻辑
    while(true)
    {
        cout<<"Please Enter# ";
        char buffer[1024];
        fgets(buffer,sizeof(buffer)-1,stdin);

        write(sock,buffer,strlen(buffer));//复用了一下缓冲区

        ssize_t s=read(sock,buffer,sizeof(buffer)-1);
        if(s>0)
        {
            buffer[s]=0;
            cout<<"server echo# "<<buffer<<endl;
        }

        //
    }

    return 0;
}

测试程序

现在服务端和客户端编写完毕,下面我们进行测试。测试时我们先启动服务端,然后通过netstat命令进行查看,此时我们就能看到一个名为tcp_server的服务进程,该进程当前处于监听状态。
在这里插入图片描述


然后再通过./tcp_client IP地址 端口号的形式运行客户端,此时客户端就会向服务端发起连接请求,获取连接成功后,服务端就会为该客户端提供服务。

在这里插入图片描述

当客户端向服务端发送消息后,服务端对接收到的信息进行打印,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息。

如果此时客户端退出了,那么服务端在调用read函数时得到的返回值就是0,此时服务端也就知道客户端退出了,进而会终止对该客户端的服务。

虽然此时服务端对该客户端的服务终止了,但是不是服务器终止了,此时服务器依旧在运行,它在等待下一个客户端的连接请求。

单执行流服务器的弊端

  • 当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。
  • 但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。

客户端为什么会显示连接成功?
这里涉及到我们之前提到的连接队列,如果有多个客户端同时发来连接请求,此时未被服务器处理的新连接就会放入连接队列,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。

  • 只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。

单执行流的服务器一次只能给一个客户端提供服务,服务器的资源并没有得到充分利用。

因此,我们可以通过引入多进程或多线程将服务器改为多执行流的。

多进程版的TCP网络程序

通过创建子进程的方式来支持多连接

  • 当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。

  • 由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。


等待子进程问题

我们需要知道,当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待。

阻塞式等待与非阻塞式等待:

  • 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。
  • 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。

因此,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出

不等待子进程退出的方式:

  1. 捕捉SIGCHLD信号,将其处理动作设置为忽略。
  2. 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务。

捕捉SIGCHLD信号

我们在创建子进程后 ,在子进程执行完任务退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程的退出情况了。

signal(SIGCHLD,SIG_IGN);//在Linux中,父进程忽略子进程的SIGCHLD信号,子进程会自动退出释放资源
    for (;;)
    {
        struct sockaddr_in peer;
        socklen_t len=sizeof(peer);
        int new_sock=accept(listen_sock,(struct sockaddr*)&peer,&len);
        if(new_sock<0)
        {
            continue;
        }
        
         uint16_t cli_port=ntohs(peer.sin_port);//网络转主机
         string cli_ip=inet_ntoa(peer.sin_addr);


         cout<<"get a new link -> :["<<cli_ip<<":"<<cli_port<<"]#"<<new_sock<<endl;
    
    //version2 版本
        pid_t id=fork();
        if(id<0)
        {
            continue;
        }
        else if(id==0)//曾经被父进程打开的fd,是否会被子进程继承呢? 会 ;因此无论是父子进程中的哪一个,强烈建议关闭不需要的fd
        {
            //child
            //因为文件描述符会被继承,所以子进程也能看到父进程的fd
            close(listen_sock);//关闭不需要的fd(从父进程继承的监听套接字)
            ServiceIO(new_sock);//子进程提供服务
            close(new_sock);//关闭子进程自己的文件描述符
            exit(0);
        }
        else{
            //father 不需要等待,因为进行了捕捉SIGCHLD信号
            //do nothing
            close(new_sock);//父进程关闭从accept函数获取的文件描述符
        }
         
     }
   

关于文件描述符的关闭问题

需要注意的是,子进程创建后会继承父进程的文件描述符。

  • 通过fork()创建子进程时,子进程继承父进程环境和上下文的大部分内容的拷贝,其中就包括文件描述符表。

  • 对于父进程在fork()之前打开的文件来说,子进程都会继承,与父进程共享相同的文件偏移量。

比如父进程打开了一个文件,该文件对应的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件。

但是当父进程创建子进程后,父子进程之间会保持独立性,此时父进程文件描述符表的变化不会影响子进程。

基于以上说明,我们在设计程序的同时应该关闭不需要的文件描述符—

  • 对于父进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为父进程会不断调用accept函数获取新的文件描述符(服务套接字),如果父进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
  • 而对于子进程来说,还是建议关闭从父进程继承下来的监听套接字。因为子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字指向的偏移量当中的数据造成影响。
程序测试

程序运行服务端后,可以通过以下监控脚本对进程进行监控。
在这里插入图片描述

while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done

在这里插入图片描述
此时可以看到,一开始没有客户端连接该服务器,此时服务进程只有一个,该服务进程就是不断获取新连接的进程,而获取到新连接后也是由该进程创建子进程为对应客户端提供服务的。
此外,我们可以看到因为我们关闭了不必要的文件描述符,每次有新的连接,我们都将文件描述符控制在了4,避免了可用的文件描述符就会越来越少的情况。


  • 此时我们运行一个客户端1,让该客户端连接服务器,此时父进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务。
  • 我们再运行一个客户端2,让该客户端连接服务器,此时父进程会再创建出一个子进程,让该子进程为这个客户端提供服务。

在这里插入图片描述


由于这两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应,这样就实现了多线程并发。
在这里插入图片描述


当客户端一个个退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个进程(父进程A),这个服务进程的任务就是不断获取新连接。在这里插入图片描述


我们通过捕捉SIGCHLD信号解决了子进程的等待问题,接下来我们实现第二种方式:让孙子进程为客户端提供服务

让孙子进程提供服务

我们让服务端创建出来的子进程再次进行fork,让孙子进程为客户端提供服务。

说明:

  • 父进程:在服务端调用accept函数获取客户端连接请求的进程。
  • 子进程:由父 进程调用fork函数创建出来的进程。
  • 孙子进程:由子进程再次调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务。

实现原理

首先我们要明确,因为我们的目的是让fork出来的子进程进行提供服务,并且父进程不需要等待提供服务的进程。

那么我们可以利用二次fork的孙子进程来实现这一目的:

for (;;)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int new_sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
        if (new_sock < 0)
        {
            continue;
        }

        uint16_t cli_port = ntohs(peer.sin_port); //网络转主机
        string cli_ip = inet_ntoa(peer.sin_addr);

        cout << "get a new link -> :[" << cli_ip << ":" << cli_port << "]#" << new_sock << endl;

       
        // version2 版本
        pid_t id = fork();
        if (id < 0)
        {
            continue;
        }
        else if (id == 0) //曾经被父进程打开的fd,是否会被子进程继承呢? 会 ;因此无论是父子进程中的哪一个,强烈建议关闭不需要的fd
        {
            // child
            //因为文件描述符会被继承,所以子进程也能看到父进程的fd
            close(listen_sock); //关闭不需要的fd
            if (fork() > 0)//创建了孙子进程,并且子进程进入此判断条件
                exit(0); //退出的是子进程

            //向后走的进程,其实是孙子进程
            ServiceIO(new_sock);
            close(new_sock); //关闭文件描述符
            exit(0);
        }
        else
        {
            // father 不需要等待
            // do nothing
            waitpid(id, nullptr, 0); //对子进程的退出进行等待;这里等待会不会被阻塞呢,不会,因为父进程创建子进程后,子进程就立马退出了
            close(new_sock);
        }
    }
  • 我们在创建了子进程后又创建了孙子进程,然后子进程立马退出了,接下来孙子进程调用 ServiceIO(new_sock);进行提供服务,最后孙子进程再退出。
  • 对于孙子进程,因为子进程在创建它后立马退出了,所以提供服务的孙子进程就变成了孤儿进程,因此在它退出后会直接被系统领养,当孙子进程提供完服务后直接由系统进行回收,所以孙子进程的等待问题是不需要子进程考虑的。
  • 但是子进程的退出确实需要父进程需要通过waitpid进行等待的,不过这里的等待是不会被阻塞的(因为父进程创建子进程后,子进程就立马退出了),所以就没有了等待实际意义上的成本代价。
程序测试

编译程序运行客户端,使用监控脚本对服务进程进行实时监控:

while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done

在这里插入图片描述
此时没有客户端连接服务器,只监控到了一个服务进程,该服务进程正在等待客户端的请求连接。


在这里插入图片描述
我们运行一个客户端1,让该客户端连接当前这个服务器,此时服务进程会创建出子进程,子进程再创建出孙子进程,之后子进程就会立刻退出,而由孙子进程为客户端提供服务。因此这时我们只看到了两个服务进程(子进程创建后立马退出了),其中一个是用于获取连接的服务进程,还有一个就是孙子进程,该进程为当前客户端提供服务,它的PPID为1,表明这是一个孤儿进程


在这里插入图片描述
当我们运行第二个客户端连接服务器时,此时就又会创建出一个孤儿进程为该客户端提供服务。


在这里插入图片描述

当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的服务进程。


总结

  • 创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现

但是,单纯的多线程版的服务器也存在问题:

  • 每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程。
  • 如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。计算机当中的线程越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高。此外,一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答。

所以,我们可以考虑引入之前学到的线程池,线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度。

对于多线程的使用和线程池的引入我们之后有机会再进行实现,大家也可以自行尝试实现一下~

评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

…狂奔的蜗牛~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值