socket网络通信基础

目录

一、套接字编程基本流程

二、TCP流式协议及Socket编程的recv()和send()

三、读写无阻塞-完美掌握I/O复用

select()函数详解

poll()函数详解

epoll () 函数详解

一、套接字编程基本流程

原文链接:Socket编程权威指南(一)打通网络通信的任督二脉_seqpacket-优快云博客

Socket进行编程通常包括以下几个步骤:

    1. 创建Socket
    2. 绑定 Socket(绑定地址信息)
    3. 监听连接请求(TCP服务器)
    4. 接受客户端链接
    5. 发送和接收数据
    6. 关闭Socket

1、创建socket:socket()返回新创建的套接字描述符(sockfd,一个非负整数)

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

创建初始套接字描述符,用于指定通信协议(ipv4..)、套接字类型(tcp/udp..)、以及特定协议(通常默认)

2、绑定套接字地址:bind() 返回0

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

将创建的套接字描述符与服务器的ip+port进行绑定,用于指定服务器的地址将用哪种传输协议进行传输。

sockaddr通常是sockaddr_in 结构。

struct sockaddr_in{
 sa_family_t sin_family;//类型,ipv4
 uint16_t sin_port;
 struct in_addr sin_addr;
 char sin_zero[8];
};

3、服务器监听客户端连接请求:listen()返回0

int listen(int sockfd, int backlog);

sockfd是绑定地址后的套接字描述符(必须在bind()后面);backlog指定内核应该排队的最大未完成连接的数量。这个值应该足够大,以避免在高负载情况下丢失连接请求。

  • 服务器首先创建一个套接字并绑定到一个地址。
  • 调用 listen() 使套接字变为被动监听模式。
  • 服务器随后可以使用 accept() 函数接受客户端的连接请求。

4、服务器接收客户端连接:accept()返回一个新的套接字描述符(clifd),用于与已连接的客户端通信

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

  • sockfd:已经调用过 listen() 的套接字描述符。
  • addr:(可选)指向 sockaddr 结构的指针,用于存储连接客户端的地址信息。如果不需要客户端地址,可以设置为 NULL
  • addrlen:(可选)指向 socklen_t 类型的指针,用于存储 addr 结构的大小。如果 addrNULL,这个参数也会被忽略。

用于接受一个已经建立的连接请求,通常在服务器端使用。当服务器调用 listen() 函数后,它会进入监听状态,等待客户端的连接请求。

5、进行数据传输:read()、write()

6、关闭连接:close(clifd)、close(sockfd)

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

int main() {
    // 创建Socket
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    
    // 绑定地址信息
    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(8000);
    bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
    
    // 监听连接
    listen(sockfd, 5);
    
    // 接收客户端连接
    struct sockaddr_in cliaddr;
    socklen_t cliaddrlen = sizeof(cliaddr);
    int clifd = accept(sockfd, (struct sockaddr*)&cliaddr, &cliaddrlen);
    
    // 读取客户端发送数据并回射
    char buffer[1024];
    ssize_t nbytes = read(clifd, buffer, sizeof(buffer));
    write(clifd, buffer, nbytes);
    
    // 关闭连接
    close(clifd);
    close(sockfd);
    
    return 0;
}

二、TCP流式协议及Socket编程的recv()和send()

原文链接:Socket编程权威指南(二)完美掌握TCP流式协议及Socket编程的recv()和send()_enotconn-优快云博客

TCP 作为流式协议,其设计目标是提供可靠的数据传输服务。它通过多种机制确保数据的正确、有序传输,并通过拥塞控制和流量控制适应不同的网络条件。

拥塞控制是确保可靠数据传输协议有效运作的关键组成部分,因此,在TCP中,发送缓冲区和接收缓冲区成为了必不可少的元素。

在标准的Linux操作系统中,TCP的发送缓冲区和接收缓冲区默认的大小通常被设置为208KB。这意味着,如果进程A没有及时从其接收缓冲区中提取数据,那么传入的数据将继续在缓冲区内积累,直至达到其容量上限。


由于TCP面向字节流传输,因此不同TCP包到达接收缓冲区需要从一连串的字节流中区分出哪个包(粘包问题)。

采用包头+包体的策略可以解决以上问题:

  • Step 1: 首先从接收缓冲区读取固定大小的包头(例如20字节)。
  • Step 2: 解析包头,从中获取数据包的总长度,这里假设包头中包含的数据长度字段名为Header.Length
  • Step 3 : 根据Header.Length的值,确定接下来需要从接收缓冲区读取的数据量。

例如,如果包头之后的数据总长度为1048字节,减去已读取的20字节包头,还需读取1028字节的数据。


对于建立连接后的数据传输,通常使用recv()和send()。

1、recv()

//ssize_t :表示可以存储任意对象大小的有符号整数

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

  • sockfd:套接字描述符,表示要从中读取数据的 TCP 套接字。
  • buf:指向一个缓冲区的指针,用于存储接收到的数据。
  • len:缓冲区的大小,即 buf 可以存储的最大字节数。
  • flags:用来修改recv()行为的选项。常用的值包括:
    • 0:正常接收数据。
    • MSG_PEEK:窥视接收的数据,不从接收缓冲区中移除数据。
    • MSG_WAITALL:等待直到接收到 len 个字节的数据,或者出现错误。

返回值:成功时,返回接收到的字节数,该值通常小于或等于 len

(1)使用场景

  • 主要用于 TCP 套接字上的数据接收。对于 UDP 套接字,通常使用 recvfrom() 函数。

(2)阻塞和非阻塞行为

  • 默认情况下,recv() 是阻塞的,它会等待直到至少接收到一个字节的数据。
  • 对于非阻塞套接字,如果接收缓冲区中没有数据,recv() 会立即返回,返回值为 0。

(3)与 read() 的区别

  • read() 是一个通用的系统调用,用于读取文件描述符,而 recv() 专门用于套接字。
  • recv() 可以处理套接字选项和状态,而 read() 不能。

2、send()

本质上是向发送缓冲区中写入数据,内核在发送 TCP 数据时,通常会使用 Nagle 算法把多个小的数据包合并成一个发送给另一端,以提高效率。

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

  • sockfd:套接字描述符,表示要从中发送数据的套接字。
  • buf:指向要发送数据缓冲区的指针。
  • len:要发送数据的长度,单位为字节。
  • flags:用来修改发送行为的选项。常用的值包括:
    • 0:正常发送数据。
    • MSG_DONTWAIT:使 send() 调用非阻塞。
    • MSG_MORE:暗示更多的数据要发送,可以用于优化传输效率。

返回值:成功时,返回已发送的字节数,该值通常小于或等于 len

(1)使用场景

主要用于已连接的 TCP 套接字上的数据发送。对于 UDP 套接字,通常使用 sendto() 函数。

(2)阻塞和非阻塞行为

  • 默认情况下,send() 是阻塞的,它会等待直到数据被发送。

对于非阻塞套接字,如果数据不能立即发送,send() 会返回 -1 并设置 errnoEAGAINEWOULDBLOCK

(3)与 write() 的区别

  • write() 是一个通用的系统调用,用于写文件描述符,而 send() 专门用于套接字。
  • send() 可以处理套接字选项和状态,而 write() 不能。

为了实现更为复杂的非阻塞操作,recv()和send()可以结合select()和poll()。

三、读写无阻塞-完美掌握I/O复用


问题描述:

tcp流式协议以及recv()和send()这两个关键函数,用于从套接字中读取和发送数据。不过,仅依赖这两个函数存在一个明显的缺陷:如果一个套接字阻塞了,整个进程将无法处理其他套接字,效率低下。


问题解决思路:

为了解决这个问题,I/O复用模型应运而生,它使用单个线程高效地监视多个文件描述符。

I/O 复用模型的工作原理:

    • 基本概念: I/O 复用模型通过将 I/O 操作与特定的事件关联起来,使得进程或线程可以在数据准备好时才进行操作,而不是不断地轮询。
    • 使用系统调用: I/O 复用通常依赖于特定的系统调用,如 select()poll(), 和 epoll()(在 Linux 上)。这些调用允许进程监控多个 I/O 描述符的状态。
    • 监控 I/O 描述符: 进程提供一个 I/O 描述符的列表给 I/O 复用系统调用,请求监控这些描述符上特定的事件,例如可读、可写或异常状态。
    • 阻塞等待: I/O 复用调用本身可能是阻塞的,直到以下情况发生:
      • 至少有一个 I/O 描述符准备好了 I/O 操作。
      • 超时时间到达,即使没有 I/O 描述符准备好。
    • 事件通知: 当 I/O 复用系统调用返回时,它会通知进程哪些 I/O 描述符已经准备好了 I/O 操作,进程可以据此执行相应的操作。
    • 提高效率: 与为每个 I/O 流创建线程或进程相比,I/O 复用可以显著减少并发处理的开销,因为它通过单个系统调用管理多个 I/O 流。
  • select() 函数select() 是最基本的 I/O 复用机制,它允许进程监控多个描述符的 I/O 状态,但它有一些限制,如描述符数量的限制和性能问题。
  • poll() 函数poll() 提供与 select() 类似的功能,但没有描述符数量的限制,但仍然存在性能问题,尤其是在大量描述符时。
  • epoll() 函数epoll() 是 Linux 特有的 I/O 复用机制,它比 select()poll() 更高效,因为它使用事件通知机制,并且可以处理大量描述符。
  • 水平触发与边缘触发: I/O 复用可以工作在两种模式下:
    • 水平触发(Level-triggered):只要条件满足,每次调用都会返回。
    • 边缘触发(Edge-triggered):只有在状态变化时才返回,可以提高性能,但编程模型更复杂。

select()函数详解

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

    • nfds:监视的文件描述符集合中最大的描述符加一(描述符集合的数量)
    • readfds:指向需要监视读状态的文件描述符集合的指针。
    • writefds:指向需要监视写状态的文件描述符集合的指针。
    • exceptfds:指向需要监视异常状态的文件描述符集合的指针。
    • timeout:指向超时时间的指针,可以是 NULL 表示无限期等待。

select() 被调用时,它会阻塞直到以下情况之一发生:

    • 至少有一个文件描述符准备好了 I/O 操作。
    • 发生了异常。
    • 超时时间到达。

select() 会更新传入的集合参数,以反映哪些文件描述符已经准备好 I/O 操作

返回值:成功时,返回准备好的文件描述符的数量。

宏功能的说明:FD_ZERO()FD_SET()FD_CLR(), 和 FD_ISSET() 是与 select() 函数一起使用的宏,它们用于操作文件描述符集合fd_set)。

(1)、FD_ZERO()
作用:将 fd_set 结构初始化为零,即清空集合中的所有文件描述符。

用法:FD_ZERO(&fdset); 其中 fdset 是 fd_set 类型的变量。


(2)、FD_SET()
作用:将指定的文件描述符添加到 fd_set 结构中。

用法:FD_SET(fd, &fdset); 其中 fd 是要添加的文件描述符,fdset 是 fd_set 类型的变量。

注意:如果文件描述符已经在集合中,再次调用 FD_SET() 不会有任何效果。


(3)、FD_CLR()
作用:从 fd_set 结构中删除指定的文件描述符。

用法:FD_CLR(fd, &fdset); 其中 fd 是要删除的文件描述符,fdset 是 fd_set 类型的变量。

注意:如果文件描述符不在集合中,调用 FD_CLR() 没有效果。


(4)、FD_ISSET()
作用:检查指定的文件描述符是否在 fd_set 结构中。

用法:if (FD_ISSET(fd, &fdset)) { ... } 其中 fd 是要检查的文件描述符,fdset 是 fd_set 类型的变量。

返回值:如果文件描述符在集合中,返回非零值(通常是 1);如果不在集合中,返回 0。

select()配合宏功能的使用步骤:

  • 使用 FD_ZERO() 初始化 fd_set 结构。
  • 使用 FD_SET() 将需要监视的文件描述符添加到集合中。
  • 调用 select() 函数,传入 fd_set 结构。
  • 调用 select() 后,使用 FD_ISSET() 检查哪些文件描述符已经准备好 I/O 操作。
  • 使用 FD_CLR() 从集合中删除已经处理过的文件描述符,以便在下一次 select() 调用中不再监视它们。

注意事项:

  • select() 有文件描述符数量的限制(通常是 1024),对于大量并发连接,可能需要使用 poll()epoll() 等更高级的 I/O 复用技术。
  • 在调用 select() 之前,需要使用 FD_ZERO()FD_SET()FD_CLR()FD_ISSET() 等宏来初始化和操作文件描述符集合。
// 在这个例子中,我们首先将监听套接字加入masterfds集合。
//然后在每次循环中,将masterfds复制到readfds,并调用select()进行监视。
//如果监听套接字就绪,则接受新的连接并将数据套接字加入masterfds。
//如果数据套接字就绪,则可以对其进行读写操作。
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>

int main() {
    //创建服务器端socket套接字
    int listensock = socket(AF_INET, SOCK_STREAM, 0);
    
    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(8000);
    //绑定套接字与服务器地址端口号
    bind(listensock, (struct sockaddr*)&servaddr, sizeof(servaddr));
    //监听该套接字
    listen(listensock, 5);
    //定义文件描述符集合
    fd_set readfds, masterfds;
    //初始化
    FD_ZERO(&masterfds);
    //将服务器读状态套接字放入master文件描述符集合(其实就是表示是否本套接字有连接情况)
    FD_SET(listensock, &masterfds);
    
    while (true) {
        //循环将监听套接字集合更新到read集合中
        readfds = masterfds;
        //select阻塞监视监听套接字集合,会更新传入的集合,只留下已经准备好IO操作的文件描述符
        int nfds = select(listensock + 1, &readfds, NULL, NULL, NULL);
        //如果监听套接字就绪,与客户端套接字建立连接
        if (FD_ISSET(listensock, &readfds)) {
            struct sockaddr_in cliaddr;
            socklen_t cliaddrlen = sizeof(cliaddr);
            //建立连接
            int datafds = accept(listensock, (struct sockaddr*)&cliaddr, &cliaddrlen);
            //把客户端套接字(数据套接字)加入到master,用于后续更新
            FD_SET(datafds, &masterfds);
        }
        //实现一个监听套接字监听多个数据套接字的IO操作
        for (int datafds = 0; datafds < nfds; datafds++) {
            //如果数据套接字就绪进行数据传输操作
            if (FD_ISSET(datafds, &readfds)) {
                // Handle data from datafds
            }
        }
    }
    
    close(listensock);
    return 0;
}

select()函数的限制:文件描述符数量限制。

具体原因:

select使用的文件描述符集合的数据结构为fd_set:

typedef struct {
    unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;

这是一个固定大小的数组,FD_SETSIZE 是一个常量,定义了 fd_set 可以表示的最大文件描述符数量。通常这个值是 1024。


poll()函数详解

select()使用描述符集来监视描述符,主要存在两方面缺陷:

(1)采用fd_set结构,导致存在最大文件描述符数量的限制,无法适应大量并发IO操作

(2)存在复制描述符集的开销,主要原因是每次调用select()时,它会修改传入的集合fd_set,只保留已经准备好进行 I/O 操作的描述符,因此需要在进入循环开始先重置描述符集合,存在复制开销。

    • fd_set 的修改机制
      • 设置准备好的文件描述符:

在调用 select() 之前,你会用 FD_SET 宏将感兴趣的文件描述符添加到 fd_set 中。

select() 返回时,它会修改传入的 fd_set只保留那些已经准备好进行 I/O 操作的文件描述符。

      • 清除未准备好的文件描述符:

select()清除(unset)那些没有准备好的文件描述符。这意味着,如果一个文件描述符在调用 select() 时没有准备好,它将不再存在于返回的 fd_set 中。


poll() 是一种 I/O 多路复用系统调用,它提供了一种机制来监视多个文件描述符(file descriptors)的状态,类似于 select() 函数。poll()函数克服了select()的部分缺陷,它采用pollfd结构数组(std::vector<struct pollfd> fds;)来监视,而不是描述符集,避免了每次调用时复制描述符集的开销。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

    • fds:指向 struct pollfd 数组的指针,数组中的每个元素都包含了要监视的文件描述符和相关的事件类型。
    • nfds:数组 fds 中元素的数量。
    • timeout:等待时间,单位为毫秒。如果设置为 -1,表示无限期等待;设置为 0 表示非阻塞调用,立即返回。

struct pollfd 结构:

struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 需要监视的事件类型 */
    short revents;    /* 事件发生后的状态 */
};
    • fd:需要监视的文件描述符。
    • events:需要监视的事件类型,可以是以下宏的组合:
      • POLLIN:有数据可读。
      • POLLOUT:写入不会阻塞。
      • POLLPRI:有紧急数据可读。
      • POLLERR:发生错误。
      • POLLHUP:对端关闭连接。
      • POLLNVAL:文件描述符不是有效的监视对象。
    • revents:实际发生的事件,函数返回后由系统填充。

返回值:成功时,返回准备好的文件描述符的数量。

工作原理:

    • 初始化 pollfd 数组:为每个需要监视的文件描述符设置一个 pollfd 结构,并指定需要监视的事件类型。
    • 调用 poll():传入 pollfd 数组、数组的大小和超时时间。
    • 等待事件poll() 函数会阻塞,直到以下情况之一发生:
      • 至少有一个文件描述符准备好了 I/O 操作。
      • 超时时间到达。
    • 处理结果poll() 函数返回后,检查 pollfd 数组中的 revents 字段,以确定哪些事件发生了。

 

epoll () 函数详解

select()和poll()虽然调用的是IO复用的机制,但是前者存在最大描述符数量限制以及描述符集合复制开销;后者存在每次调用poll时加入全部描述符,因此这两种方法都无法应对大量的并发IO操作。

epoll 在处理大量并发连接时具有明显的优势,因为它使用基于事件的模型,可以减少 CPU 和内存的使用。


1、核心概念

  • epoll 实例:使用 epoll_create() 创建,代表一个监视的集合理解为一个监视多个文件描述符事件的对象
  • 事件:可以是读、写、错误等。
  • 文件描述符:需要被监视的 I/O 对象。
  • 回调机制:当文件描述符上的事件发生时,epoll 会通知应用程序。

2、函数原型

  • 创建 epoll 实例
#include <sys/epoll.h>
//自从Linux2.6.8版本以后,size值其实是没什么用的,不过要大于0,因为内核可以动态的分配大小,
//所以不需要size这个提示了。
int epoll_create(int size);

size:建议的初始大小,实际上创建的实例大小由内核决定。

  • 添加/修改文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    • epfd:epoll 实例的文件描述符,监视多个文件描述符的对象,由epoll_create创建。
    • op:操作类型,可以是 EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)或 EPOLL_CTL_DEL(删除)。
    • fd:需要监视的文件描述符。
    • event:指向 epoll_event 结构的指针,指定了要监视的事件和相关的回调数据。
  • 等待事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    • epfd:epoll 实例的文件描述符。
    • events:用于存储发生的事件的数组
    • maxevents:数组 events 的最大容量。
    • timeout:等待时间,单位为毫秒。如果设置为 -1,表示无限期等待。

epoll_event 结构:

struct epoll_event {
    uint32_t events;     /* Epoll events */
    epoll_data_t data;   /* User data variable */
};
  • events:事件掩码,可以是以下宏的组合:
    • EPOLLIN:有数据可读。
    • EPOLLOUT:写入不会阻塞。
    • EPOLLPRI:有紧急数据可读。
    • EPOLLERR:发生错误。
    • EPOLLHUP:对端关闭连接。
  • data:用户自定义的数据,可以是任何类型的指针,用于在事件发生时传递额外信息。

3、工作原理

  • 第一步,创建 epoll 实例:使用 epoll_create() 创建一个 epoll 实例。
  • 第二步,添加文件描述符:使用 epoll_ctl() 将需要监视的文件描述符添加到 epoll 实例中,并设置要监视的事件。
  • 第三步,等待事件:调用 epoll_wait() 等待事件发生。与 select()poll() 不同,epoll_wait() 只返回已经发生的事件,减少了不必要的轮询。
  • 第四步,处理事件:遍历 epoll_wait() 返回的事件数组,处理每个发生的事件。
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    
    int epfd = epoll_create(1); // 创建 epoll 实例
    //event定义监视的事件类型;events数组保存实际发生的事件
    struct epoll_event event, events[10];

    if (epfd == -1) {
        perror("epoll_create");
        return 1;
    }

    // 初始化事件
    event.data.fd = STDIN_FILENO; // 监视标准输入
    event.events = EPOLLIN;

    // 添加文件描述符到 epoll 实例
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event) == -1) {
        perror("epoll_ctl");
        return 1;
    }

    // 等待事件,把发生的事件放入events数组中,并返回已经发生的事件的数量
    int nfds = epoll_wait(epfd, events, 10, -1);
    if (nfds == -1) {
        perror("epoll_wait");
        return 1;
    }

    // 处理事件
    for (int i = 0; i < nfds; i++) {
        if (events[i].events & EPOLLIN) {
            printf("Data is available to read on fd %d\n", events[i].data.fd);
        }
    }

    close(epfd);
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值