Linux:TCP Socket编程(代码实战)

Posted It‘s so simple

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux:TCP Socket编程(代码实战)相关的知识,希望对你有一定的参考价值。


1. TCP的Socket编程

1.1 TCP的编程流程

和UDP编程一样,我们在考虑TCP的时候也要考虑到接收方和发送方,也就是是说要考虑接收方和发送方的,但是由于TCP是有连接,可靠有序且面向字节流的传输层协议,因此,TCP的编程流程要比UDP复杂一些。

用图来表示的话如下:
在这里插入图片描述

1.2 TCP Socket的接口

创建套接字socket()、关闭套接字close()和绑定地址信息bind()这三个函数均和UDP的一样,可以看我之前写的这篇文章

1.2.1 服务端监听连接接口

int listen(int sockfd, int backlog);

参数:

  • sockfd:套接字描述符
  • backlog:已完成连接队列的大小

    注:服务端和客户端在进行三次握手的过程中,会存在两个队列,一个是已完成连接的队列,一个是未完成连接的队列,未完成连接的队列中存储的是没有完成三次握手的连接;而已完成连接的队列中存储的是已经完成三次连接,并且等待被服务端"accept"的连接,或者也可以理解为当前连接状态为"ESTABLISHED"的连接。

backlog参数的具体理解:

由于TCP连接首先要进行三次握手,而想要和服务端建立连接的客户端却有很多个,在同一时间内可能会有多个客户端再和服务端进行三次握手。而backlog控制的就是已完成连接队列的大小,因此backlog影响了服务端在一瞬间的连接的接收能力,或者说影响了服务端并发接收新连接的能力,注意不能将其理解为 服务端建立连接的数量。

1.2.2 服务端获取新连接接口

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

作用:

从已完成连接队列中获取已经完成三次握手的连接。(当没有连接的时候,调用该接口会发送阻塞)

参数:

  • sockfd:套接字描述符
  • addr:客户端的地址信息结构
  • addrlen:客户端的地址信息结构的长度

返回值:(非常重要)

成功则返回新连接的套接字描述符,失败则返回-1。

accept返回值的深入理解:

如图:
在这里插入图片描述
这就是为什么返回值如此重要原因,因为他关系着后面收发数据时所对应在服务端中的缓冲区时哪一个。

1.2.3 客户端发起连接接口

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

参数:

  • sockfd:套接字描述符
  • addr:服务端的地址信息结构
  • addrlen:服务端的地址信息结构的长度

返回值:

成功则返回0,失败则返回小于0的数。

1.2.4 发送数据接口

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

参数:

  • sockfd:套接字描述符
  • buf:待要发送的数据
  • len:待要发送数据的长度
  • flags:0为阻塞发送,MSG_OOB为发送带外数据

带外数据:即在紧急情况下所产生的数据,会越过前面进行排队的数据优先进行发送。

返回值:

成功则返回的字节数量,失败则返回-1。

1.2.5 接收数据接口

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

参数:

  • sockfd:套接字描述符
  • buf:将接收到的数据放到buf中
  • len:接收数据的长度
  • flags:0为阻塞接收,即如果没有发送数据,则recv接口会阻塞

返回值:

  • >0:正常接收了多少字节的数据
  • ==0:对端将连接关闭(即对端机器调用了close函数关闭了套接字)
  • >0:接收失败

1.3 三次握手的简单验证

由于三次握手是操作系统内核自动完成的,因此我们只需要给出服务端的的代码到listen接口即可。

#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <iostream>

using namespace std;


int main()
{
    //1. 创建套接字
    int sockfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
    if(sockfd < 0)
    {
        cout << "TCP socket failed" << endl;
        return 0;
    }

    //2.绑定地址信息
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(18989);
    addr.sin_addr.s_addr = inet_addr("0.0.0.0");
    int ret = bind(sockfd,(struct sockaddr *)&addr,sizeof(addr));
    if(ret < 0)
    {
        cout << "bind failed" << endl;
        return 0;
    }

    //3.进行侦听(告诉OS内核可以与客户端建立连接了)
    //listen函数中blocklog参数,设定的是已完成三次握手并已建立连接的队列的容量
    ret = listen(sockfd,1);
    if(ret < 0)
    {
        cout << "listen failed" << endl;
        return 0;
    }
    while(1)
    {};
    return 0;
}

要测试三次握手是否成功,我们不需要编写客户端代码进行验证,而是借助windows下的一个工具telnettelnet可以模仿TCP进行三次握手,就只是单纯的建立连接,其他什么都不会做。

使用telnet + 公网ip + 端口号即可对其进行测试,需要注意的是,这条命令是在windows下的cmd窗口运行的。

首先我们需要将服务端运行起来然后再使用telnet进行测试
在这里插入图片描述
当一回车之后,会新弹出一个telnet的窗口,代表三次握手成功。
在这里插入图片描述
下面给出两种失败的情况
情况1:端口不存在
在这里插入图片描述
情况2:公网ip错误
在这里插入图片描述

我们多使用telnet建立几次连接,然后看看当前服务器端口的使用情况。
这里总共使用telnet与当前服务器进行了三次的连接,即进行了三个三次握手,而我们在代码中设置的已完成连接的队列的大小为1,我们可以使用netstat -anp | grep 18989查看18989端口的使用情况。
在这里插入图片描述
我们可以清楚的看到该服务端建立了两次连接,但是我们设置的backlog为1,按理说只能连接一次,那这是什么情况呢?原因是操作系统内核的问题,内核中的代码逻辑是这样的

// q为 已完成连接队列   ,capacity_为当前我们设置的backlog的大小
if(q.size() < capacity_)
{
	//不建立连接 
}

因此,根据这个逻辑来分析,他会建立两次连接,是正常的,并不是我们代码的问题。
但是有一点需要注意的是,在验证三次握手的时候,我们服务器端的代码绝对不能用accept来接收,因为一旦用accept接收之后,就会从当前已完成队列中取走一个连接,导致我们看到的连接结果会多一条,如图:
在这里插入图片描述

2. 代码实战

本次TCP的代码有三种形式:单进程版本、多进程版本和多线程版本,但是这些版本均只是针对服务端而言,其中单进程和多进程的客户端的代码都是相同的,因此在这里我们首先给出客户端的代码。

#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <iostream>

using namespace std;

/*
 * 1. 创建套接字
 * 2. 建立连接请求
 * 3. TCP三次握手建立连接----OS会自动进行,不需要我们进行控制
 * 4. 接收数据、发送数据
 * 5. 关闭套接字
 */ 

int main()
{
    //1. 创建套接字
    int sockfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
    if(sockfd < 0)
    {
        cout << "TCP socket failed" << endl;
        return 0;
    }

    //2.进行连接
    //0.0.0.0:任意网卡地址,注意不是任一,它的含义就是哪个网卡地址都行
    //当服务器端绑定的地址为0.0.0.0,那么我们客户端想要与服务端建立连接的时候
    //有两种方法: 
    //1.若是运行服务器的这台机器本身是能够上网的,
    // 那么我们只需要连接它的网卡地址中的任意一个就行,(0.0.0.0为任意地址)
    //2.若是运行服务器的这台机器是要经过类似于腾讯云、阿里云这样的
    // 云服务器读对私网ip进行转换才能上网的,那么我们就需要连接它的公网ip。
    
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(18989);
    //此处我们使用的公网ip
    addr.sin_addr.s_addr = inet_addr("118.89.67.215");

    int ret = connect(sockfd,(struct sockaddr *)&addr,sizeof(addr));
    if(ret < 0)
    {
        cout << "connnect failed" << endl;
        return 0;
    }

    //3.接收和发送数据
    while(1)
    {
        //这里接收数据和发送数据是没有顺序而言的
        //因为已经经过三次握手,客户端和服务端的连接以及建立成功了
        char buf[1024] = {0};
        
        //发送数据
        sprintf(buf,"hello server,i am client2\\n");
        ssize_t send_ret = send(sockfd,buf,strlen(buf),0); 
        if(send_ret < 0)
        {
            cout << "send failed" << endl;
            continue;
        }
        memset(buf,'\\0',sizeof(buf));

        ssize_t recv_size = recv(sockfd,buf,sizeof(buf)-1,0);
        if(recv_size < 0)
        {
            cout << "recv failed" << endl;
            continue;
        }
        else if(recv_size == 0)
        {
            cout << "Peer close" << endl;
            close(sockfd);
            return 0;
        }
        
        cout << "recv data is : " << buf << endl;


    }

    close(sockfd);
    return 0;
}

2.1 单进程版本的TCP代码

单进程版本的服务端代码如下:

#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <iostream>

using namespace std;

/*
 * 1. 创建套接字
 * 2. 绑定地址信息
 * 3. 进行监听--(告诉OS内核可以接收客户端发送的新请求了并且监听新连接的到来)
 * 4. TCP三次握手建立连接----OS会自动进行,不需要我们进行控制
 * 5. 接收新连接
 * 6. 接收数据、发送数据
 * 7. 关闭套接字
 */ 

int main()
{
    //1. 创建套接字
    int sockfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
    if(sockfd < 0)
    {
        cout << "TCP socket failed" << endl;
        return 0;
    }

    //2.绑定地址信息
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(18989);
    addr.sin_addr.s_addr = inet_addr("0.0.0.0");
    int ret = bind(sockfd,(struct sockaddr *)&addr,sizeof(addr));
    if(ret < 0)
    {
        cout << "bind failed" << endl;
        return 0;
    }

    //3.进行侦听(告诉OS内核可以与客户端建立连接了)
    //listen函数中blocklog参数,设定的是已完成三次握手并已建立连接的队列的容量
    ret = listen(sockfd,1);
    if(ret < 0)
    {
        cout << "listen failed" << endl;
        return 0;
    }

    //4.接收新连接
    struct sockaddr_in client_addr;
    socklen_t socklen = sizeof(client_addr);
    int new_sockfd = accept(sockfd,
    			(struct sockaddr *)&client_addr,&socklen);

    if(new_sockfd < 0)
    {
        cout << "accept failed" << endl;
        return 0;
    }

    printf("accept success: %s:%d, and new_sockfd is %d\\n",
    inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),
    			new_sockfd);

    //5.接收和发送数据
    while(1)
    {
        //这里接收数据和发送数据是没有顺序而言的
        //因为已经经过三次握手,客户端和服务端的连接以及建立成功了
        
        char buf[1024] = {0};

        ssize_t recv_size = recv(new_sockfd,buf,sizeof(buf)-1,0);
        if(recv_size < 0)
        {
            cout << "recv failed" << endl;
            continue;
        }
        else if(recv_size == 0)
        {
            cout << "Peer close" << endl;
            close(new_sockfd);
            close(sockfd);
            return 0;
        }
        
        cout << "accrpt data is : " << buf << endl;

        //发送数据
        memset(buf,'\\0',sizeof(buf));
        sprintf(buf,"hello lient,i am server,i am %s:%d\\n",
        		inet_ntoa(addr.sin_addr),ntohs(addr.sin_port));
        		
        ssize_t send_ret = send(new_sockfd,buf,strlen(buf),0); 
        if(send_ret < 0)
        {
            cout << "send failed" << endl;
            continue;
        }

    }

    close(sockfd);
    return 0;
}

分析完代码后,我们来看看客户端与服务端之间的通信吧!!

分别运行客户端和服务端所得到的结果如下:
服务端的结果:
在这里插入图片描述
客户端的结果:
在这里插入图片描述
看上去好像成功了,但是这只是一个客户端与服务器通信的情况,如果是多个客户端呢?他的情况是怎样的?在上面的基础上,我们再运行一个客户端,看看其结果如何:
在这里插入图片描述
我们发现第二个客户端完全不运行,这是什么情况呢?我们使用ps aux | grep []pstack []命令来对其进行分析。
在这里插入图片描述
稍微对服务端代码进行分析就可知,因为服务端accept在while循环外面,所以只能从已完成连接队列中拿出一次连接进行通信,而第二个客户端进来后,虽然和服务端三次握手成功建立了连接,但是并没有被服务端接收其连接,因此当客户端代码走到recv逻辑的时候就会发生阻塞,因为客户端是不会给他回复任何消息的。

那能否将服务端accept代码放到while循环里面?答案肯定也是不行的,稍微分析一下,当while循环每次走到accept逻辑的时候,都要从已完成连接队列中获取连接,当队列为空的时候就会阻塞掉,因此,当有两个客户端的时候,它们之间只能通信一次,然后就会陷入到accept阻塞的逻辑中。

稍微总结一下,单进程版本的TCP代码当有多个客户端与其服务器进行通信的时候,依据代码逻辑的不同,可能会产生两种阻塞:recv阻塞和accept阻塞。

那么该如何解决呢?有三种解决办法。

  • 多进程版本的TCP代码
  • 多线程版本的TCP代码
  • 使用多路转接的技术(这个在后面会进行讲解)

2.2 多进程版本的TCP代码

再写多进程代码的时候,一定要注意的点有:父进程只管accept,子进程只管send和recv数据,因为父子进程的进程虚拟空间是几乎相同的,父进程从已完成连接队列中得到的套接字,子进程也能拥有。

但是一定要注意到进程等待,如果子进程先于父进程退出,且父进程没有进行相应的进程等待,那么该子进程就会变成僵尸进程。但是如果我们直接在父进程的逻辑中加上wait函数是行不通的,因为,随着客户端的增多,父进程创建出的子进程也逐渐变多,wait函数会捕捉子进程退出时所发出的SIGCHLD信号,但是是不知道捕捉的是哪一个进程的。

因此,在这里,我们使用signal函数将SIGCHLD该信号处理重新用我们自己的逻辑定义一下即可。

服务端的代码如下:

#include <unistd.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <signal.h>
#include <netinet/in.h>
#include <string.h>
#include <iostream>

using namespace std;

void signalcallBack(int)
{
    wait(NULL);
    return;
}

int main()
{
    signal(SIGCHLD,signalcallBack);
   
    //创建套接字
    int listen_sock = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
    if(listen_sock < 0)
    {
        cout << "socket failed" << endl;
        return 0;
    }
    
    //绑定地址信息
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(18989);
    addr.sin_addr.s_addr = inet_addr("0.0.0.0");
    int ret = bind(listen_sock,(struct sockaddr *)&addr,sizeof(addr));
    if(ret < 0)
    {
        cout << "bind failed" << endl;
        return 0;
    }

    //帧听
    ret = listen(listen_sock,1);
    if(ret < 0)
    {
        cout << "listen failed" << endl;
        return 0;
    }

    while(1)
    {
        struct sockaddr_in peer_addr;
        socklen_t peerlen;
        int new_sockfd = accept(listen_sock,
        				(struct sockaddr *)&peer_addr,&peerlen);
        if(new_sockfd < 0)
        {
            cout << "accept failed" << endl;
            return 0;
        }
        pid_t fork_ret = fork();
        if(fork_ret < 0)
        {
            cout << "fork failed" << endl;
            continue;
        }
        else if(fork_ret == 0)
        {
            close(listen_sock);
            //child
            //在子进程中只实现接收和发送数据
            while(1)
            {
                char buf[1024] = {0};
             ssize_t recv_size = recv(new_sockfd,buf,sizeof(buf)-1,0);
                if(recv_size < 0)
                {
                    cout << "recv failed" << endl;
                    continue;
                }
                else if(recv_size == 0)
                {
                    cout << "peer failed" << endl;
                    break;
                }

                cout << "i recv: " << buf << endl;

                //接收数据
                memset(buf,'\\0',sizeof(buf));
                sprintf(buf,"hello client,%s:%d\\n",
                		inet_ntoa(peer_addr.sin_addr),
                		ntohs(peer_addr.sin_port));
                ssize_t send_size = send(new_sockfd,buf,strlen(buf),0);
                if(send_size < 0)
                {
                    cout << "send failed" << endl;
                    break;
                }
            }
        }

        else
        {
            //father
            //要进行进程等待,防止子进程变成僵尸进程
            //但是还存在着的问题是,该父进程可能会创建多个子进程
            //若是直接在父进程中进行进程等待的话,就意味着若只有一个子进程退出
            //父进程也会随之等待到资源,然后continue上去会陷入到accept的逻辑中
            //因此,我们需要设置信号,自定义信号的处理逻辑,每当有一个sigchild
            //信号发送出来的时候,我们让其在自定义的处理函数进行进程等待即可
            continue;
        }
    }

    return 0;
}

可能是我上传的图片太多了,上传图片总是失败,所以这里就不再进行结果的验证了。

2.3 多线程版本的TCP代码

多线程和多进程一样,也需要考虑几点,要将新的套接字传入线程入口函数,为此我们要在堆上申请一个空间用来存储新的套接字;为了不让线程进行等待,我们可以在线程入口函数中直接将自己线程分离掉即可;最后一点,一定要注意退出时对内存进行释放,关闭套接字,避免内存泄漏。

我在这里用一个类将TCP的各个接口给封装了起来,换句话说,到时候可以在客户端或服务端直接调用封装的类的接

以上是关于Linux:TCP Socket编程(代码实战)的主要内容,如果未能解决你的问题,请参考以下文章

Linux:UDP Socket编程(代码实战)

Linux:UDP Socket编程(代码实战)

socket编程实战

iOS Socket/Tcp编程 GCDAsyncSocket的实战(带回调)

Linux-TCP编程流程-Socket编程-单线程实现TCP客户端和服务端交互-多进程实现TCP客户端和服务端交互

Linux----网络编程socket