网络编程套接字(tcp)(代码片段)

三分苦 三分苦     2023-04-04     154

关键词:

目录

1、实现一个TCP网络程序(单进程版)

        1.1、服务端serverTcp.cc文件

                 服务端创建套接字

                 服务端绑定

                 服务端监听

                 服务端获取连接

                 服务端提供服务

                 服务端main函数命令行参数

                 服务端serverTcp.cc总代码

        1.2、客户端clientTcp.cc文件

                 客户端main函数命令行参数

                 客户端创建套接字

                 客户端的bind、listen、accept问题

                 客户端连接服务器

                 客户端发起请求

                 客户端clinetTcp.cc总代码

        1.3、服务器测试

        1.4、单执行流服务器的问题

2、多进程版的TCP网络程序

        捕捉SIGCHLD信号

        让孙子进程提供服务

3、多线程版的TCP网络程序

4、线程池版的TCP网络程序

        线程池变形

5、总代码gitee链接


1、实现一个TCP网络程序(单进程版)

1.1、服务端serverTcp.cc文件

我们把服务器封装成一个ServerTcp类,该类里主要有如下几个任务:

  • 服务端创建套接字
  • 服务端绑定
  • 服务端监听
  • 服务端获取链接
  • 服务端提供服务
  • 服务端main函数命令行参数

下面依次演示:


服务端创建套接字

我们把服务器封装成一个ServerTcp类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器首先要创建套接字。创建套接字的函数叫做socket函数,再回顾下其函数原型:

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

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

  • domain:协议家族选择AF_INET,因为我们要进行的是网络通信。
  • type:创建套接字时所需的服务器类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。注意我UDP是用户数据报服务。
  • protocol:协议类型默认设置为0即可。

若socket创建失败,则复用logMessage函数打印相关日志信息,并直接exit退出程序。

class ServerTcp

public:
    // 构造函数 + 析构函数
public:
    // 初始化
    void init()
    
        // 1、创建socket
        sock_ = socket(AF_INET, SOCK_STREAM, 0);
        if (sock_ < 0)
        
            logMessage(FATAL, "socket: %s", strerror(errno)); // 创建失败,打印日志
            exit(SOCKET_ERR);
        
        logMessage(DEBUG, "socket: %s, %d", strerror(errno), sock_);
    
private:
    int sock_;      // socket
    uint16_t port_; // port
    string ip_;     // ip
;

服务端绑定

  • 当套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。所以我们需要调用bind函数进行绑定操作。

绑定的步骤如下:

  • 1、绑定网络信息,先填充基本信息到struc sockaddr_in结构体。
  • 定义struc sockaddr_in结构体对象local,复用memset函数对local进行初始化。将协议家族、端口号、IP地址等信息填充到该结构体变量当中。注意协议家族这里设定的是PF_INET。
  • 服务器的端口号是要发给对方的,在发送到网络之前要复用htons主机转网络函数把端口号port_转成网络序列,才能向外发送。
  • ip地址默认是字符串风格点分十进制的,这里复用inet_aton函数将字符串IP转换成整数IP(inet_addr除了做转换,还会自动给我们做主机转网络)。注意若ip地址是空的,那就用INADDR_ANY这个宏,否则再用inet_addr函数。这个宏就是0,因此在设置时不需要进行网络字节序的转换。
  • 2、绑定网络信息,上述local临时变量(struc sockaddr_in结构体对象)是在用户栈上开辟的,要将其写入内核中。复用bind函数完成绑定操作。bind成功与否均复用logMessage函数打印相关日志信息。
  • 由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in*强转为struct sockaddr*类型后再进行传入。
class ServerTcp

public:
    // 构造函数 + 析构函数
public:
    // 初始化
    void init()
    
        // 1、创建socket
        // 2、bind绑定
        // 2.1、填充服务器信息
        struct sockaddr_in local; // 用户栈
        memset(&local, 0, sizeof(local));
        local.sin_family = PF_INET;
        local.sin_port = htons(port_);
        ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr));
        // 2.2、将本地socket信息,写入sock_对应的内核区域
        if (bind(sock_, (const struct sockaddr *)&local, sizeof(local)) == -1)
        
            logMessage(FATAL, "bind: %s", strerror(errno)); // 绑定失败,打印日志
            exit(BIND_ERR);
        
        logMessage(DEBUG, "bind: %s, %d", strerror(errno), sock_);
    
private:
    int sock_;// socket
    uint16_t port_; // port
    string ip_;     // ip
;

服务端监听

listen接口说明

  • UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
  • 因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。

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

int listen(int sockfd, int backlog);

参数说明:

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

返回值说明:

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

代码逻辑如下

  • TCP是面向连接的,所以要让TCP服务器时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。监听失败就打印日志信息,并直接退出。因为监听失败就意味着TCP服务器无法接受客户端发来的连接请求。
class ServerTcp

public:
    // 构造函数 + 析构函数
public:
    // 初始化
    void init()
    
        // 1、创建socket
        // 2、bind绑定
        // 3、监听socket
        if (listen(sock_, 5) < 0)
        
            logMessage(FATAL, "listen: %s", strerror(errno)); // 监听失败,打印日志
            exit(LISTEN_ERR);
        
        logMessage(DEBUG, "listen: %s, %d", strerror(errno), sock_); 
    
private:
    int sock_;      // socket
    uint16_t port_; // port
    string ip_;     // ip
;

初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。为了表明寓意,我们将代码中套接字的名字由sock_改为listensock_。


服务端获取连接

accept接口说明

  • TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。究竟是谁连接我的。

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

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

参数说明:

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

返回值说明:

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

调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。监听套接字与accept函数返回的套接字的作用:

  • 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
  • accept函数返回的套接字:用于为本次accept获取到的连接提供服务(为用户提供网络服务,主要是进行IO)。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。

代码逻辑如下

  • 定义struct sockaddr_in的对象peer,定义len为peer的字节数
  • 复用accept函数获取连接。若返回值<0说明连接失败,但是TCP服务器不会因为某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。
  • 获取连接成功后,要获取客户端的基本信息,将客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
class ServerTcp

public:
    // 构造函数 + 析构函数
public:
    // 初始化
    void init()
    
        // 1、创建socket
        // 2、bind绑定
        // 3、监听socket
    
    // 启动服务端
    void loop()
    
        while (true)
        
            // 4、获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len);
            if (serviceSock < 0)
            
                logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败
                continue;
            
            // 4.1、获取客户端基本信息
            uint16_t peerPort = ntohs(peer.sin_port);
            string peerIp = inet_ntoa(peer.sin_addr);
            logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);
        
    

private:
    int listensock_;// socket
    uint16_t port_; // port
    string ip_;     // ip
;

服务端接受连接测试

  • 这里我们客户端还没有写,但是我们可以先允许服务端,然后在windows下的浏览器上用当前云服务器ip(124.71.25.237)+端口号(8080)进行访问测试
  • 浏览器常见的应用层协议是http或https,其底层对应的也是TCP协议,因此浏览器也可以向当前这个TCP服务器发起请求连接。测试如下:

注意:

  • 至于这里为什么浏览器一次会向我们的TCP服务器发起两次请求这个问题,这里就不作讨论了,我们只是要证明当前TCP服务器能够正常接收外部的请求连接。

服务端提供服务

read接口说明

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

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

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

参数说明:

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

返回值说明:

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

read返回值为0表示对端连接关闭。这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:

  • 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
  • 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
  • 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。
  • 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。

这里的写端就对应客户端,如果客户端将连接关闭了,那么此时服务端将套接字当中的信息读完后就会读取到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。

write接口说明

  • TCP服务器写入数据的函数叫做write,该函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);

参数说明:

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

返回值说明:

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

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

代码逻辑如下

  • 注意:服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
  • 这里我们把服务端提供服务的过程封装成一个transService函数,其内部完成的主要功能是完成大小写转化
  • 首先,调用read函数读取客户端发来的数据,这里且假定读取的是字符串。read函数返回值为s。
  • 若返回值s > 0,说明读取成功,在内部首先调用strcasecmp函数判断客户端是否需要服务端提供服务,若不需要(quit),则打印日志并退出,若需要,在内部完成大小写转化的功能。转化完成后调用write函数将结果返回给客户端
  • 若返回值s = 0或s < 0,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
class ServerTcp

public:
    // 构造函数 + 析构函数
public:
    // 初始化
    void init()
    
        // 1、创建socket
        // 2、bind绑定
        // 3、监听socket
    
    // 启动服务端
    void loop()
    
        while (true)
        
            // 4、获取连接
            // 4.1、获取客户端基本信息
            // 5、提供服务,echo ( 小写 -> 大写 )
            // 5.0 v0版本
            transService(serviceSock, peerIp, peerPort);
        
    
    // 大小写转化服务
    // TCP && UDP: 支持全双工
    void transService(int sock, const string &clientIp, uint16_t clientPort)
    
        assert(socket >= 0);
        assert(!clientIp.empty());
        assert(clientPort >= 1024);
        char inbuffer[BUFFER_SIZE];
        while (true)
        
            ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为读取到的都是字符串
            if (s > 0)                                              // 读取成功
            
                inbuffer[s] = '\\0';                    // read success
                if (strcasecmp(inbuffer, "quit") == 0) // strcasecmp是忽略大小写比较的函数
                
                    // 客户端输入退出
                    logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                    break;
                
                logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer);
                // 可以进行大小写转化了
                for (int i = 0; i < s; i++)
                
                    if (isalpha(inbuffer[i]) && islower(inbuffer[i]))
                        inbuffer[i] = toupper(inbuffer[i]);
                
                logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer);

                // 进行写回操作
                write(sock, inbuffer, strlen(inbuffer));
            
            else if (s == 0) // 对方关闭
            
                logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                break;
            
            else // 读取出错
            
                logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno));
                break;
            
        
        // 只要走到这里,一定是client退出了,服务到此结束
        close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,则文件描述符泄露
        logMessage(DEBUG, "server close %d done", sock);
    

private:
    int listensock_; // 监听套接字socket
    uint16_t port_;  // port
    string ip_;      // ip
;

服务端main函数命令行参数

将来我们的服务端在启动的时候,在命令行中一定是按照如下格式输入的:

./ServerTcp local_port local_ip

我们需要给main函数加上命令行参数,内部代码逻辑如下:

  • 利用命令行参数的形式,若main函数中argc != 2 && argc != 3,则复用提示信息函数Usage,并exit退出进程
  • 定义port端口为命令行的第二个参数(下标为1的参数)
  • 若argc == 3,则定义ip地址为命令行的第三个参数(下标为2的参数)
  • 将端口号和ip地址传入ServerTcp服务器的类里,调用init和start函数
static void Usage(string proc)

    cerr << "Usage:\\n\\t" << proc << "port ip" << endl;
    cerr << "Example:\\n\\t" << proc << "8080 127.0.0.1\\n" << endl;

// ./ServerTcp local_port local_ip
int main(int argc, char *argv[])

    if (argc != 2 && argc != 3)
    
        Usage(argv[0]);
        exit(USAGE_ERR);
    
    uint16_t port = atoi(argv[1]);
    string ip;
    if (argc == 3)
        ip = argv[2];
    ServerTcp svr(port, ip);
    svr.init();
    svr.loop();
    return 0;

服务端serverTcp.cc总代码

ServerTcp类的成员变量如下:

  • listensock_
  • port_
  • ip_

ServerTcp类的成员函数如下:

  • ServerTcp构造函数
  • ServerTcp析构函数
  • init初始化函数
  • loop启动服务器函数

总代码如下:

#include "utli.hpp"

class ServerTcp

public:
    ServerTcp(uint16_t port, const string &ip = "")
        : port_(port), ip_(ip), listensock_(-1)
    
    ~ServerTcp()
    

public:
    // 初始化
    void init()
    
        // 1、创建socket
        listensock_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listensock_ < 0)
        
            logMessage(FATAL, "socket: %s", strerror(errno)); // 创建失败,打印日志
            exit(SOCKET_ERR);
        
        logMessage(DEBUG, "socket: %s, %d", strerror(errno), listensock_);

        // 2、bind绑定
        // 2.1、填充服务器信息
        struct sockaddr_in local; // 用户栈
        memset(&local, 0, sizeof(local));
        local.sin_family = PF_INET;
        local.sin_port = htons(port_);
        ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr));
        // 2.2、将本地socket信息,写入listensock_对应的内核区域
        if (bind(listensock_, (const struct sockaddr *)&local, sizeof(local)) == -1)
        
            logMessage(FATAL, "bind: %s", strerror(errno)); // 绑定失败,打印日志
            exit(BIND_ERR);
        
        logMessage(DEBUG, "bind: %s, %d", strerror(errno), listensock_);

        // 3、监听socket
        if (listen(listensock_, 5) < 0)
        
            logMessage(FATAL, "listen: %s", strerror(errno)); // 监听失败,打印日志
            exit(LISTEN_ERR);
        
        logMessage(DEBUG, "listen: %s, %d", strerror(errno), listensock_);
        // 允许别人连接你了
    
    // 启动服务端
    void loop()
    
        while (true)
        
            // 4、获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len);
            if (serviceSock < 0)
            
                logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败
                continue;
            
            // 4.1、获取客户端基本信息
            uint16_t peerPort = ntohs(peer.sin_port);
            string peerIp = inet_ntoa(peer.sin_addr);
            logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);
            // 5、提供服务,echo ( 小写 -> 大写 )
            // 5.0 v0版本 —— 单进程
            transService(serviceSock, peerIp, peerPort);
        
    
    // 大小写转化服务
    // TCP && UDP: 支持全双工
    void transService(int sock, const string &clientIp, uint16_t clientPort)
    
        assert(socket >= 0);
        assert(!clientIp.empty());
        assert(clientPort >= 1024);
        char inbuffer[BUFFER_SIZE];
        while (true)
        
            ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为读取到的都是字符串
            if (s > 0)                                              // 读取成功
            
                inbuffer[s] = '\\0';                    // read success
                if (strcasecmp(inbuffer, "quit") == 0) // strcasecmp是忽略大小写比较的函数
                
                    // 客户端输入退出
                    logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                    break;
                
                logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer);
                // 可以进行大小写转化了
                for (int i = 0; i < s; i++)
                
                    if (isalpha(inbuffer[i]) && islower(inbuffer[i]))
                        inbuffer[i] = toupper(inbuffer[i]);
                
                logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer);

                // 进行写回操作
                write(sock, inbuffer, strlen(inbuffer));
            
            else if (s == 0) // 对方关闭
            
                logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                break;
            
            else // 读取出错
            
                logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno));
                break;
            
        
        // 只要走到这里,一定是client退出了,服务到此结束
        close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,则文件描述符泄露
        logMessage(DEBUG, "server close %d done", sock);
    

private:
    int listensock_; // 监听套接字socket
    uint16_t port_;  // port
    string ip_;      // ip
;

static void Usage(string proc)

    cerr << "Usage:\\n\\t" << proc << "port ip" << endl;
    cerr << "Example:\\n\\t" << proc << "8080 127.0.0.1\\n"
         << endl;

// ./ServerTcp local_port local_ip
int main(int argc, char *argv[])

    if (argc != 2 && argc != 3)
    
        Usage(argv[0]);
        exit(USAGE_ERR);
    
    uint16_t port = atoi(argv[1]);
    string ip;
    if (argc == 3)
        ip = argv[2];
    ServerTcp svr(port, ip);
    svr.init();
    svr.loop();
    return 0;

1.2、客户端clientTcp.cc文件

这里我们不像服务端udpServer.cc一样进行封装成类了。其内部主要框架逻辑如下:

  1. main函数采用命令行参数
  2. 客户端创建套接字
  3. 通讯过程(启动客户端)

下面依次演示


客户端main函数命令行参数

客户端在启动的时候必须要知道服务端的ip和port,才能进行连接服务端。未来的客户端程序一定是这样运行的:

./clientTcp serverIp serverPort
  • 如果命令行参数个数argc != 3,复用Usage函数输出相关提示信息,并退出程序
  • 定义string类型的serverIp变量保存命令行的第二个参数
  • 定义serverPort变量保存命令行中的第三个参数
static void Usage(string proc)

    cerr << "Usage:\\n\\t" << proc << "serverIp serverPort" << endl;
    cerr << "Example:\\n\\t" << proc << "127.0.0.1 8080\\n" << endl;

// ./clientTcp serverIp serverPort
int main(int argc, char* argv[])

    if (argc != 3)
    
        Usage(argv[0]);
        exit(USAGE_ERR);
    
    string serverIp = argv[1];
    uint16_t serverPort = atoi(argv[2]);
    return 0;

客户端创建套接字

客户端创建套接字时选择的协议家族也是AF_INET,需要的服务类型也是SOCK_STREAM。与服务端不同的是,客户端在初始化时只需要创建套接字就行了,而不需要进行绑定操作。

int main()

    ...
    // 1、创建socket
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0)
    
        cerr << "socket: " << strerror(errno) << endl;
        exit(SOCKET_ERR);
    
    ...
    close(sock);
    return 0;

客户端的bind、listen、accept问题

客户端需不需要自己进行bind绑定呢?

  • 不需要。所谓的“不需要”,指的是:客户端不需要用户自己bind端口信息!因为OS会自动给你绑定。(这个问题和udp的一样)

客户端需不需要自己进行listen监听呢?

  • 不需要。监听本来就是等着别人来连你,作为客户端,你是要主动连接别人的,而不是等着服务端自动向你连接的,这属实反客为主了。
  • 而服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。

客户端需不需要自己进行accept获取呢?

  • 不需要,因为都没有listen,都没有人来连你,当然不用accpet

客户端连接服务器

connect接口说明

  • 客户端创建完套接字后需要向服务器发送链接请求。发起连接请求的函数叫做connect,该函数的函数原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数说明:

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

返回值说明:

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

代码逻辑如下

  • 定义struct sockaddr_in类型的结构体指针server,复用memset函数对其清零
  • 填写服务器对应的信息,将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
  • 注意要复用htons主机转网络函数把端口号转成网络序列,才能向外发送。
  • 注意要复用inet_aton函数将字符串IP转换成整数IP
  • 复用connect函数向服务器发送连接请求
int main(int argc, char *argv[])

    // 1、创建socket
    // 2、connect, 向服务器发起连接请求
    // 2.1、先填充需要连接的远端主机的基本信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverPort);
    inet_aton(serverIp.c_str(), &server.sin_addr);
    // 2.2、发起请求,connect 回自动帮我们进行bind
    if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0)
    
        cerr << "connect: " << strerror(errno) << endl;
        exit(CONN_ERR);
    
    cout << "info: connect success: " << sock << endl;
    return 0;

客户端发起请求

  • 由于我们实现的是一个简单的回声服务器,因此当客户端连接到服务端后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务端,发送时调用write函数向套接字当中写入数据即可。
  • 当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用read函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误。
int main(int argc, char *argv[])

    // 1、创建socket
    // 2、connect, 向服务器发起连接请求
    // 2.1、先填充需要连接的远端主机的基本信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverPort);
    inet_aton(serverIp.c_str(), &server.sin_addr);
    // 2.2、发起请求,connect 回自动帮我们进行bind
    if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0)
    
        cerr << "connect: " << strerror(errno) << endl;
        exit(CONN_ERR);
    
    cout << "info: connect success: " << sock << endl;

    string message;
    while (!quit)
    
        message.clear();
        cout << "请输入你的消息>>> ";
        getline(cin, message);
        if (strcasecmp(message.c_str(), "quit") == 0)
            quit = true;
        ssize_t s = write(sock, message.c_str(), message.size());
        if (s > 0)
        
            message.resize(1024);
            ssize_t s = read(sock, (char*)(message.c_str()), 1024);
            if (s > 0)  message[s] = 0;
            cout << "Server Echo>>> " << message << endl;
        
        else if (s <= 0)
        
            break;
        
    
    return 0;

客户端clinetTcp.cc总代码

clientTcp.cc文件的内部主要框架逻辑如下:

  • main函数使用命令行参数:
  • 客户端创建套接字
  • 连接过程

总代码如下:

#include "utli.hpp"

volatile bool quit = false;

static void Usage(string proc)

    cerr << "Usage:\\n\\t" << proc << "serverIp serverPort" << endl;
    cerr << "Example:\\n\\t" << proc << "127.0.0.1 8080\\n" << endl;

// ./clientTcp serverIp serverPort
int main(int argc, char *argv[])

    if (argc != 3)
    
        Usage(argv[0]);
        exit(USAGE_ERR);
    
    string serverIp = argv[1];
    uint16_t serverPort = atoi(argv[2]);
    // 1、创建socket
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0)
    
        cerr << "socket: " << strerror(errno) << endl;
        exit(SOCKET_ERR);
    
    // 2、connect, 向服务器发起连接请求
    // 2.1、先填充需要连接的远端主机的基本信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverPort);
    inet_aton(serverIp.c_str(), &server.sin_addr);
    // 2.2、发起请求,connect 回自动帮我们进行bind
    if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0)
    
        cerr << "connect: " << strerror(errno) << endl;
        exit(CONN_ERR);
    
    cout << "info: connect success: " << sock << endl;

    string message;
    while (!quit)
    
        message.clear();
        cout << "请输入你的消息>>> ";
        getline(cin, message);
        if (strcasecmp(message.c_str(), "quit") == 0)
            quit = true;
        ssize_t s = write(sock, message.c_str(), message.size());
        if (s > 0)
        
            message.resize(1024);
            ssize_t s = read(sock, (char*)(message.c_str()), 1024);
            if (s > 0)  message[s] = 0;
            cout << "Server Echo>>> " << message << endl;
        
        else if (s <= 0)
        
            break;
        
    
    close(sock);
    return 0;

1.3、服务器测试

现在服务端和客户端均已写好,先运行服务端,再运行客户端。我们使用如下指令辅助我们观察现象:

[xzy@ecs-333953 tcp]$ sudo netstat -ntp | grep -E 'serverTcp|clientTcp'

如上我服务器的端口是8081,它已经和端口43914的客户端相互建立起了连接:

现在就可以让客户端向服务端发送消息了,当客户端向服务端发送消息后,服务端可以通过打印的IP地址和端口号识别出对应的客户端,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息。

当我客户端发送quit退出动作时,服务端识别后,确认客户端退出,并关闭对应的socket。如果我强制ctrl -c退出客户端,OS会自动帮我们关掉对应的文件描述符,此时服务端也就知道客户端退出了,进而会终止对客户端的服务。


1.4、单执行流服务器的问题

当我们仅用一个客户端连接服务器时,这一个客户端能够正常享受到服务端的服务:

但当此客户端1正常享受服务端的服务时,我们让另一个客户端2也连接此服务器, 此时发现两个客户端都是可以正常连接的,但是客户端2发给服务端的消息并没有在服务端进行打印,服务端也没有将该数据回显给客户端2。相反我客户端1和服务端是能够正常通信的:

但是当客户端1退出后,服务端才将客户端2发来的数据进行打印,并回显到客户端2上:

单进程的服务器

  • 通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务,一旦进入transService函数,主执行流就无法进行向后执行,只能提供完毕服务之后才能进行accept。
  • 当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。

 解决办法

  •  单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,此时就要引入多进程多线程

2、多进程版的TCP网络程序

  • 当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
  • 由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕
  • 父进程创建的子进程会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务。

等待子进程问题

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

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

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

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

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

捕捉SIGCHLD信号

实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。

class ServerTcp

public:
    // 构造 + 析构
public:
    // 初始化
    // 启动服务端
    void loop()
    
        signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号
        while (true)
        
            // 4、获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len);
            if (serviceSock < 0)
            
                logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败
                continue;
            
            // 4.1、获取客户端基本信息
            uint16_t peerPort = ntohs(peer.sin_port);
            string peerIp = inet_ntoa(peer.sin_addr);
            logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);
            // 5、提供服务,echo ( 小写 -> 大写 )
            // 5.0 v0版本 —— 单进程
            // transService(serviceSock, peerIp, peerPort);

            // 5.1 v1版本 —— 多进程
            pid_t id = fork();
            assert(id != -1);
            if (id == 0)
            
                close(listensock_); // 建议关掉
                // 子进程
                transService(serviceSock, peerIp, peerPort);
                exit(0); // 子进程退出进入僵尸
            
            // 父进程
            close(serviceSock); // 一定要做
        
    

private:
    int listensock_; // 监听套接字socket
    uint16_t port_;  // port
    string ip_;      // ip
;

测试结果:

我们使用如下的监控脚本辅助我们观察现象:

[xzy@ecs-333953 tcp]$ ps -axj | head -1 && ps axj | grep serverTcp
  • 当我们让客户端1连接服务器后,服务器进程会调用fork函数创建出一个子进程并提供服务;当我们让客户端2连接服务器后,服务器进程同样会调用fork函数创建出一个子进程并提供服务。所以我们会看到3个进程在运行的状态:
  • 如下我们还应该看到客户端1和客户端2各自向服务端发送信息,且都能正常收到服务端的回复。

现在我们让客户端一个一个退出,并用如下的监控脚本观察进程数量的变化:

[xzy@ecs-333953 tcp]$ while :; do ps -axj | head -1 && ps axj | grep serverTcp ; sleep 1 ;done

当客户端一个一个推出后,服务端为之提供的子进程也会相机退出,单无论如何服务端都至少会有一个服务进程,此进程的任务就是不断获取新连接。


让孙子进程提供服务

我们可以让服务端冲断爷爷进程,服务端创建的子进程(爸爸进程)继续fork创建子进程(孙子进程),让孙子进程为客户端提供服务,过程如下:

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

不需要等待孙子进程退出

  • 我们让爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。
  • 而由于爸爸进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收孙子进程,所以服务进程(爷爷进程)是不需要等待孙子进程退出的。

关闭对应的文件描述符

  • 对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
  • 而对于爸爸进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。

代码如下:

class ServerTcp

public:
    // 构造 + 析构
public:
    // 初始化
    // 启动服务端
    void loop()
    
        signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号
        while (true)
        
            // 4、获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len);
            if (serviceSock < 0)
            
                logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败
                continue;
            
            // 4.1、获取客户端基本信息
            uint16_t peerPort = ntohs(peer.sin_port);
            string peerIp = inet_ntoa(peer.sin_addr);
            logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);
            // 5、提供服务,echo ( 小写 -> 大写 )
            // 5.0 v0版本 —— 单进程
            // transService(serviceSock, peerIp, peerPort);

            // 5.1 v1版本 —— 多进程 ———— 捕捉SIGCHLD信号
            // 5.1 v1.1版本 —— 多进程 ———— 让孙子进程提供服务
            // 爷爷进程
            pid_t id = fork();
            if (id == 0)
            
                // 爸爸进程
                close(listensock_); // 建议关掉
                if (fork() > 0)     // 又进行了一次fork,让爸爸进程直接终止
                    exit(0);
                // 孙子进程 ———— 没有爸爸 ———— 孤儿进程 ———— 被系统领养 ———— 回收问题就交给了系统来回收
                transService(serviceSock, peerIp, peerPort);
                exit(0);
            
            close(serviceSock); // 一定要做
            // 爸爸进程直接终止,立马得到退出码,释放僵尸状态
            pid_t ret = waitpid(id, nullptr, 0); // 就用阻塞式
            assert(ret > 0);
            (void)ret;
        
    

private:
    int listensock_; // 监听套接字socket
    uint16_t port_;  // port
    string ip_;      // ip
;

测试结果:


3、多线程版的TCP网络程序

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

当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。

  • 当然,主线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。

文件描述符关闭的问题:

由于此时所有线程看到的都是同一张文件描述符表,因此当某个线程要对这张文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。

  • 对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭。
  • 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了。

代码逻辑如下:

  • 我们使用pthread_create创建线程,让新线程内部执行为客户端提供服务transService的操作。所以我们需要在线程执行函数threadRoutine里传入客户端ip,port,sock。
  • 为了能够让线程执行函数threadRoutine获得ip,port,sock这三个参数,我们在pthread_create的最后一个参数传入一个ThreadData结构体,该结构体内部包含了这三个

    网络编程套接字(tcp)(代码片段)

    目录1、实现一个TCP网络程序(单进程版)        1.1、服务端serverTcp.cc文件                 服务端创建套接字                 服务端绑定                 服务端监听               ... 查看详情

    tcp套接字编程常用函数(代码片段)

    ...h>intsocket(intfamily,inttype,intprotocol);//调用成功返回非负的套接字描述符,出错返回-1connect函数TCP客户端用connect函数来建立与TCP服务器的连接#include<sys/socket.h 查看详情

    tcp的网络编程基础(代码片段)

            服务器建立ServerSocket对象ServerSocket对象负责等待客户端请求建立套接字连接,类似邮局某个窗口中的业务员。也就是说,服务器必须事先建立一个等待客户请求建立套接字的连接的ServerSocket... 查看详情

    c_cpplinux的下tcp的套接字编程示例(代码片段)

    查看详情

    网络编程tcp网络应用程序开发(代码片段)

    【网络编程】TCP网络应用程序开发TCP网络应用程序开发流程1.TCP网络应用程序开发流程的介绍2.TCP客户端程序开发流程的介绍3.TCP服务端程序开发流程的介绍4.小结TCP客户端程序开发1.开发TCP客户端程序开发步骤回顾2.socket类的介绍... 查看详情

    python网络编程—tcp套接字之http传输(代码片段)

    HTTP协议(超文本传输协议) 1.用途:网页获取,数据的传输  2.特点: 应用层协议,传输层使用tcp传输简单,灵活,很多语言都有HTTP专门接口无状态,协议不记录传输内容http1.1支持持久连接,丰富了请求类型3.网... 查看详情

    java网络编程-第四节:tcp流套接字(serversocket)编程(代码片段)

    文章目录一:Java流套接字通信模型二:相关API详解(1)ServerSocket(2)Socket三:TCP通信示例一:客户端发送什么服务端就返回什么(1)代码(2)效果展示(3)分析四: 查看详情

    java网络编程-第四节:tcp流套接字(serversocket)编程(代码片段)

    文章目录一:Java流套接字通信模型二:相关API详解(1)ServerSocket(2)Socket三:TCP通信示例一:客户端发送什么服务端就返回什么(1)代码(2)效果展示(3)分析四: 查看详情

    第4章基本tcp套接字编程(代码片段)

    4.1各种套接字api(重要)4.1.1socket()用于创建一个套接字描述符,这个描述符指明的是tcp还是udp,同时还有ipv4还是ipv6#include<sys/socket.h>?intsocket(intfamily,inttype,intprotocol);//成功返回描述符,错误-1family主要是指明的协议族,AF_INET... 查看详情

    socket编程-tcp(代码片段)

    server.pyimportsocketphone=socket.socket(socket.AF_INET,socket.SOCK_STREAM)#第一个参数为套接字的地址家族AF_INET代表网络套接字,第二个参数SOCK_STREAM代表tcp协议phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)#当服务端关闭时,再重启服务端 查看详情

    基本套接字编程--tcp篇(代码片段)

    1.Socket简介Socket是进程通讯的一种方式,即调用这个网络库的一些API函数实现分布在不同主机的相关进程之间的数据交换。几个定义:(1)IP地址:即依照TCP/IP协议分配给本地主机的网络地址,两个进程要... 查看详情

    套接字实现tcp服务器(代码片段)

    套接字编程又被叫做是socket编程,socket这个词可以表示很多概念:在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程,“IP地址+端口号”就称为socket。在TCP协议中,建立连接的两个进程各自有一个socket... 查看详情

    10.网络编程之socket(代码片段)

    目录一、什么是socket?1.1套接字简介1.2套接字地址:主机-端口对二、面向连接的套接字和为无连接的套接字2.1面向连接的套接字2.2无连接的套接字三、python中socket3.1socket()模块函数3.2套接字对象(内置)方法3.3Socket中的一些参数四... 查看详情

    网络编程套接字之三tcp(代码片段)

    目录1.ServerSocketAPI(给服务器端使用的类)2.SocketAPI(既给服务器使用,也给客户端使用)3.写TCP回显—服务器4.使用线程池后的TCP服务器代码(最终)5.写回显-客户端6.TCP回显—客户端代码7.运行回显服务器和客户... 查看详情

    网络编程-tcp(代码片段)

    tc相当于打电话,需要先建立链接,区分客户端与服务端。importsocketdefmain():#1.创建tcp的套接字tcp_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)#2.链接服务器#tcp_socket.connect(("192.168.33.11",7890))server_ip=input("请输入要链接的服务器的ip:")se... 查看详情

    网络编程之tcp客户端开发和tcp服务端开发(代码片段)

    开发TCP客户端程序开发步骤创建客户端套接字对象和服务端套接字建立连接发送数据接收数据关闭客户端套接字 importsocketif__name__==‘__main__‘:#创建tcp客户端套接字#1.AF_INET:表示ipv4#2.SOCK_STREAM:tcp传输协议tcp_client_socket=socket.so... 查看详情

    tcp/ip网络编程习题2(代码片段)

    1.什么是协议?在收发数据中定义协议有何意义?协议是对话中使用的通信规则.定义协议可让计算机进行正确无误的对话.2.面向连接的TCP套接字传输特性有3点,请分别说明?可靠性,传输的过程数据不会丢失字节流,按序传输数据... 查看详情

    网络编程套接字之tcp(代码片段)

    文章目录一、TCP流套接字编程ServerSocketSocketTCP长短连接二、TCP回显服务器客户端服务器客户端并发服务器UDP与TCP一、TCP流套接字编程我们来一起学习一下TCPsocketapi的使用,这个api与我们之前学习的IO流操作紧密相关,如果... 查看详情