C温故补缺(十八):网络编程

博客 分享
0 141
张三
张三 2023-06-03 05:56:06
悬赏:0 积分 收藏

C温故补缺(十八):网络编程

计算机网络

参考:TCP三次握手详解.

OSI模型

简单分层:

其中,链路层还可以分出物理层和数据链路层。应用层可以分出会话层,表示层和应用层。

七层模型:

  • 链路层:只是物理的比特流和简单封装的数据帧

  • 网络层:主要任务是,通过路由选择算法,为报文通过通信子网选择最适当的路径。也就是通过ip地址来寻址,对应的协议是IP协议。

    而ICMP,是基于IP协议的一种协议,但是按功能划分属于网络层,而不是自下而上分到传输层。该协议主要用于确认IP包是否成功到达目标地址,以及返回在发送过程中IP地址被丢弃的原因。

    ARP协议,也是网络层的,就是用来将ip地址解析成物理mac地址的,并将ip和mac关联存在ARP缓存表的协议,以便之后再访问,就不用再解析了。

  • 传输层:拎出来详细研究,见下

  • 会话层:就是用于建立会话的,主要步骤:

  1. 为会话实体间创建连接:为给两个对等会话服务用户创建一个会话连接,应该做如下几项工作。

    1. 将会话地址映射为运输地址。

    2. 选择需要的运输服务质量参数(QoS)。

    3. 对会话参数进行协商。

    4. 识别各个会话连接。

    5. 传送有限的透明用户数据。

  2. 数据传输阶段:这个阶段是在两个会话用户之间实现有组织的,同步的数据传输。用户数据单元为SSDU,而协议数据单元为SPDU.会话用户之间的数据传送过程是将SSDU转变成SPDU进行的。

  3. 连接释放:连接释放是通过"有序释放","废弃","有限量透明用户数据传送"等功能单元来释放会话连接的。

from知乎.

  • 表示层:主要负责数据格式的转换、数据加密解密、数据压缩、图片处理等工作,对接应用层。
  • 应用层:就是各种网络服务,http,https,smtp等等

TCP

借助chatgpt。

TCP(Transmission Control Protocol)协议是一种面向连接的、可靠的、基于流的传输协议,是互联网中最常用的传输协议之一。TCP协议主要用于在网络上进行可靠的数据传输,其特点是建立连接、传输数据、维护连接和释放连接。

TCP协议的主要特点如下:

  1. 面向连接:TCP协议在传输数据之前,需要先建立连接,以确保通信双方能够相互识别和配合。

  2. 可靠性:TCP协议能够保证数据能够被正确地传输和接收,通过检验和和确认机制,可以检测和纠正传输过程中出现的错误和丢包。

  3. 按顺序传输:TCP协议能够保证数据按照发送顺序进行传输和接收,避免数据的乱序和丢失。

  4. 流控制:TCP协议通过滑动窗口机制,控制发送方的数据流量,避免网络拥塞和数据包的丢失。

  5. 拥塞控制:TCP协议通过拥塞窗口控制机制,动态调整发送方的数据传输速率,避免网络拥塞和数据包的丢失。

  6. 面向字节流:TCP协议将数据看作一个字节流进行传输,不考虑数据的边界和长度,能够传输任意类型的数据。

面向连接的实现

TCP协议实现面向连接的方式主要是通过三次握手建立连接和四次挥手释放连接。

建立连接的过程如下:

  1. 客户端向服务器发送SYN(同步)请求,表示客户端要建立连接,并带有一个随机数A。

  2. 服务器收到请求后,返回SYN+ACK(同步+确认)响应,表示服务器收到了连接请求,并带有一个随机数B和确认数A+1。

  3. 客户端收到响应后,发送ACK(确认)响应,表示客户端确认收到了服务器的响应,并带有确认数B+1。

完成以上三步,TCP连接就建立成功了。

释放连接的过程如下:

  1. 客户端发送FIN(结束)请求,表示客户端不再发送数据。

  2. 服务器收到请求后,发送ACK响应,表示已经收到了FIN请求。

  3. 服务器再发送FIN请求,表示服务器不再发送数据。

  4. 客户端收到FIN请求后,发送ACK响应,表示已经收到了服务器的请求,连接正式关闭。

完成以上四步,TCP连接就被正常关闭了

可靠性实现

TCP协议实现可靠性的方式主要有以下几个方面:

  1. 序列号和确认号:TCP协议在传输数据时,使用序列号和确认号来保证数据的可靠传输。发送方在发送数据时,为每个数据包设置一个序列号,接收方在接收到数据包之后,会向发送方发送一个确认号,表示接收到了序列号对应的数据。如果发送方没有收到确认号,则会重新发送数据包。

  2. 检验和:TCP协议使用检验和来保证数据在传输过程中不被篡改。发送方在发送数据时,会计算数据包的检验和,接收方在接收到数据包后,会重新计算检验和,如果接收到的数据包的检验和与发送方发送的不一致,则会丢弃该数据包。

  3. 超时重传:TCP协议在发送数据时,会设置一个超时时间(RTT),如果在超时时间内没有接收到接收方的确认号,则会重新发送数据包,以保证数据的可靠传输。

  4. 滑动窗口:TCP协议使用滑动窗口机制来控制数据的传输速率和流量。发送方和接收方会维护一个窗口大小,发送方根据窗口大小和接收方的确认号来控制发送数据的速度和流量,接收方则根据窗口大小来控制接收数据的流量。

顺序传输的实现方式

TCP协议实现顺序传输的方式主要是通过序列号和确认号来保证数据的顺序传输。

在TCP协议中,发送方为每个数据包设置一个序列号,接收方在接收数据包时,会根据序列号来确定数据包的顺序,如果接收到的数据包的序列号不是按照顺序递增的,则会缓存该数据包,等待后面的数据包到达后再进行排序和组合。

在发送数据时,TCP协议会按照顺序将数据分成多个数据包进行传输,每个数据包都带有一个序列号,接收方会根据序列号来确定数据包的顺序,并向发送方发送确认号,表示已经接收到了序列号对应的数据包。如果发送方没有收到确认号,则会重新发送数据包,保证数据包的顺序传输。

流控制机制

TCP流控制是通过滑动窗口机制实现的。具体实现机制如下:

  1. 发送方和接收方都会维护一个滑动窗口,用于控制数据的流动。

  2. 发送方会根据接收方的窗口大小来动态调整自己的发送速率。如果接收方窗口变小了,发送方就会减慢发送速率,以避免数据的拥塞。

  3. 接收方会在收到一定量的数据后,向发送方发送一个确认消息(ACK),告诉发送方接收到了这些数据。同时,接收方会把窗口向前滑动一定的距离,让发送方继续发送数据。

  4. 如果发送方发送的数据过多,超过了接收方的窗口大小,接收方就会发送一个窗口更新消息,告诉发送方可以继续发送的数据量。

通过这样的机制,TCP流控制可以保证数据的流动速率适应网络的情况,避免数据的拥塞和丢失。同时,这种机制还可以适应不同的网络环境和数据传输需求,具有很高的灵活性和可靠性。

拥塞控制机制
  1. 发送方和接收方都会维护一个拥塞窗口(cwnd),用于控制数据的发送速率。初始时,cwnd的大小为一个最大段大小(MSS)。

  2. 发送方会根据接收方的窗口大小和拥塞窗口的大小来动态调整自己的发送速率。发送方每收到一个ACK就会把cwnd增加一个MSS的大小,以逐步增加发送速率,但是在拥塞发生时,cwnd会被减小以减少发送速率。

  3. 接收方在收到数据后,会向发送方发送一个窗口更新消息,告诉发送方可以接收的数据量。如果接收方的窗口变小了,发送方就会减慢发送速率,以避免数据的拥塞。如果发送方没有收到ACK,就会认为网络出现了拥塞,就会把cwnd减小以降低发送速率。

  4. 发送方还会根据网络的拥塞情况来调整拥塞窗口的大小。如果发送方收到了重复的ACK,就表示网络出现了拥塞,就会把cwnd减小一定的量,以避免继续发送造成更严重的拥塞。如果发送方发现没有收到ACK,就会认为网络出现了拥塞,就会把cwnd减小以降低发送速率。

通过这样的机制,TCP拥塞控制可以保证在网络出现拥塞时,发送方能够自动降低发送速率,避免数据的丢失和网络拥堵。同时,这种机制还可以适应不同的网络环境和数据传输需求,具有很高的灵活性和可靠性。

字节流的解释

TCP(传输控制协议)是一种面向字节流的协议,这意味着TCP将数据视为一个连续的字节流,而不是一系列独立的数据包或消息。传输的数据没有固定的边界或大小,TCP只是把数据看作是一个字节序列,并在传输时按照这个字节序列进行处理。

在TCP中,发送方把需要传输的数据按照字节流的形式分割成小的数据块,称为TCP段。然后,发送方把每个TCP段封装成一个TCP报文段,并在报文头中添加一些控制信息,如源端口、目的端口、序号、确认号、窗口大小等。发送方把TCP报文段发送给接收方。

接收方在收到TCP报文段后,按照报文头中的序号和确认号信息,将TCP段重新组装成原始的数据。如果接收方收到了乱序的TCP段,它会先缓存这些TCP段,等待缺失的TCP段到来后再进行组装。如果接收方收到了重复的TCP段,它会忽略这些TCP段,只发送一次ACK确认报文段。

TCP的通信流程

UDP

UDP(用户数据报协议)是一种简单的、无连接的、面向数据报的协议,它可以在IP网络中进行快速传输。与TCP协议不同,UDP协议不提供可靠性和流量控制等服务,但是它的优点是速度快,具有较低的延迟和较小的网络开销。

UDP协议的特点如下:

  1. 无连接性:UDP协议是无连接的,发送数据前不需要建立连接。这意味着应用程序可以快速地发送数据,并且不需要等待建立连接这一步骤。

  2. 面向数据报:UDP协议是面向数据报的,每个数据包都是独立的,UDP协议不会像TCP协议那样把数据流分割成小的数据块,也不会在发送和接收的数据之间维护状态信息。

  3. 不可靠性:UDP协议不提供可靠性和流量控制等服务,因此在传输过程中可能会出现数据包丢失、重复、乱序等问题。但是,这也使得UDP协议的传输速度更快,适用于那些对可靠性要求不高的应用程序。

  4. 简单性:UDP协议非常简单,它只包含了必要的功能,没有复杂的控制机制和状态信息。这使得UDP协议的实现非常容易,并且可以在资源有限的设备上使用。

UDP协议适用于一些对可靠性要求不高的应用程序,如视频流、音频流、DNS服务等。这些应用程序需要快速传输数据,而且可以容忍一定的数据丢失和重复。

无连接性

与TCP不同,UDP不会在传输之前建立连接,并且不会在传输后关闭连接。这种无连接的特性使得UDP具有更高的传输速率和更低的延迟,但也意味着数据传输的可靠性较低,因为UDP无法保证数据的完整性和正确性。

在UDP协议中,数据包只包含源地址、目标地址、数据和一些控制信息,如校验和等。这些信息足以保证数据包能够被正确地传输,但是它们不能确保数据包能够被正确地接收。如果数据包在传输过程中丢失或损坏,UDP不会自动重传数据包,而是将它们丢弃。因此,在使用UDP进行数据传输时,需要对数据的完整性和正确性进行额外的检验和控制。

面向数据报

UDP的底层使用的是IP协议,就是网络层的IP协议。在网络层中,IP协议传输的消息类型是IP数据报,它是无连接的,且不可靠的。所以UDP数据报也是无连接、不可靠的。但是因为直接使用IP协议,速度快,占用小。在UDP的基础上加上源地址、目的地址、控制信息就组成了IP数据报,直接在网络层传输。

UDP通信流程

网络编程

参考:csdn-网络通信.

基本原理

  • 服务器端:建立socket,绑定scoket和地址信息,开启监听,收到请求后发送数据

  • 客户端:建立socket,连接到服务器端,接收并打印服务器发送的数据

流程图

核心函数

  • socket:创建一个套接字

  • bind:用于绑定IP地址和端口号到socket;

  • listen:设置能处理的最大连接要求,listen并未开始接收连线,只是设置socket为listen模式

  • accept:用来接收socket连接

  • connect:用于绑定之后的client端与服务器建立连接

一些小问题

sockaddr_in结构体

sockaddr_in是用于表示IPv4地址和端口号的结构体。其定义如下:

struct sockaddr_in {
    sa_family_t sin_family; // 地址族,一般为AF_INET
    in_port_t sin_port; // 端口号,网络字节序
    struct in_addr sin_addr; // IPv4地址
    char sin_zero[8]; // 填充,一般为0
};

其中,sa_family_t类型表示地址族,一般情况下为AF_INET表示IPv4地址;in_port_t类型表示端口号,为网络字节序;struct in_addr类型表示IPv4地址,其定义如下:

struct in_addr {
    in_addr_t s_addr; // IPv4地址,网络字节序
};

in_addr_t类型表示IPv4地址,为32位无符号整数,也是网络字节序。

使用sockaddr_in结构体可以方便地表示IPv4地址和端口号。

errno变量

errno是C/C++语言中的一个全局变量,用于记录最近一次系统调用发生错误的错误码。系统调用包括文件操作、网络操作、进程操作等等。

errno变量通常定义在头文件中,其类型是int。在发生错误时,系统会将相应的错误码存储到errno变量中,以便程序员可以根据错误码进行相应的处理。

对于网络编程中的Socket库,send、recv等函数在发生错误时会设置errno变量,因此程序员可以通过检查errno变量来判断函数是否执行成功。例如,send函数在发送数据失败时会返回-1,并设置errno变量指示失败的原因。

常见的errno错误码包括:

  • EACCES:权限不够
  • EAGAIN:资源暂时不可用
  • EINTR:系统调用被信号中断
  • EINVAL:无效的参数
  • ENOMEM:内存不足
  • ECONNRESET:连接被重置
  • ETIMEDOUT:连接超时
  • EHOSTUNREACH:主机不可达

timeval

struct timeval是linux系统中定义的结构体:

struct timeval{
__time_t tv_sec;        /* Seconds. */
__suseconds_t tv_usec;  /* Microseconds. */
};

tv_sec是秒,tv_usec是微秒

__time_t和__suseconds_t都是long int的扩展名

htons

htons是一个用于将主机字节序转换为网络字节序的函数,其函数原型如下:

#include <arpa/inet.h>
uint16_t htons(uint16_t hostshort);

htons函数的参数hostshort是一个16位整数,表示要进行转换的主机字节序数据。该函数将主机字节序数据转换为网络字节序数据,然后返回转换后的结果。网络字节序采用大端字节序,即高位字节存储在低地址,低位字节存储在高地址。

htons函数将主机字节序转换为网络字节序的过程如下:

  1. 判断本地主机的字节序是大端字节序还是小端字节序。如果本地主机是大端字节序,则不需要进行转换,直接返回原始数据即可。

  2. 如果本地主机是小端字节序,则需要将主机字节序数据转换为网络字节序数据。具体操作是将低位字节存储在高地址,高位字节存储在低地址。

例如,如果要将一个16位整数0x1234(主机字节序)转换为网络字节序,htons函数将执行以下操作:

  1. 检测本地主机的字节序,如果本地主机是小端字节序,则需要进行转换。

  2. 将低位字节0x34存储在高地址,高位字节0x12存储在低地址,得到0x3412(网络字节序)。

  3. 返回转换后的结果0x3412。

需要注意的是,htons函数只能用于16位整数的转换,如果要转换32位整数,需要使用htonl函数。另外,在网络编程中,所有传输到网络上的数据都必须使用网络字节序,否则可能会导致数据传输错误。因此,在编写网络程序时,应该使用htons等函数将主机字节序数据转换为网络字节序数据。

详解SOCKET

socket函数

socket原意“插座”,在计算机通信领域,被翻译为“套接字”,是计算机之间进行通信的一种约定或一种方式,通过socket这种约定,计算机之间可以相互发送接收数据。socket的本质就是一个文件,通信的本质就是在计算之间传递这个文件。

基本语法:SOCKET socket(int af,int type,int protocol);

  • af:地址族,address family,就是IP地址的类型,值包括AF_INET(IPv4)、AF_INET6(IPv6)。

  • type:数据传输方式/套接字类型,值包括SOCK_STREAM(流格式套接字/面向连接的套接字)和SCOK_DGRAM(datagram数据报套接字/无连接的套接字)

  • protocol:协议,值包括 IPPROTO_TCP(TCP协议),IPPROTO_UDP(UDP 传输协议)

  • 返回值SOCKET是int型:

    1. 返回值为 -1:通常表示函数调用失败,可能是由于参数错误、权限不足、系统资源不足等原因引起的。

    2. 返回值为 0:通常表示一个连接已经关闭,此时应该关闭套接字并释放资源。

    3. 返回值为正整数:通常表示已经成功地进行了某种操作,具体含义要根据函数的不同而定。例如:

      • socket 函数成功地创建了一个新套接字,返回的是新套接字的描述符。

      • bind 函数成功地将一个套接字与一个本地地址绑定,返回的是 0。

      • listen 函数成功地将一个套接字设置为监听状态,返回的是 0。

      • accept 函数成功地接受了一个连接请求,返回的是新建立连接的套接字描述符。

    4. EAGAIN/EWOULDBLOCK:表示当前情况下资源已经不可用,需要等待一段时间或者采取其他措施再尝试操作。

    5. EINTR:表示当前操作被中断,可能是由于信号的到来或者其他原因引起的,需要重新尝试操作。

运用socket,首先需要相关的头文件:

  • <sys/socket.h>:定义了 socket 相关的数据类型、结构体和函数。

  • <netinet/in.h>:定义了网络地址结构体、地址族、端口号等相关的数据类型和宏定义。

  • <arpa/inet.h>:定义了一些 IP 地址转换的函数。

  • <netdb.h>:定义了一些网络数据库相关的函数,如获取主机信息、服务信息等。

例子:用socket套接字访问百度服务器

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

using namespace std;

int main() {
    // 创建 socket 套接字
    int client_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (client_sock == -1) {
        cerr << "Failed to create socket." << endl;
        return -1;
    }

    // 建立连接
    sockaddr_in server_addr;//这个结构初始是空的,所以需要申请字节空间
    memset(&server_addr, 0, sizeof(server_addr));//可以用memset
    server_addr.sin_family = AF_INET;//设置IPv4
    server_addr.sin_addr.s_addr =inet_addr("112.80.248.75");//设置IP主机号,不能是网址,必须先解析成IP
    server_addr.sin_port = htons(80);//设置端口

    if (connect(client_sock, (sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        cerr << "Failed to connect to server." << endl;
        return -1;
    }

    // 发送请求
    const char* request = "GET / HTTP/1.1\r\nHost: www.baidu.com\r\n\r\n";
    write(client_sock, request, strlen(request));//向百度的服务器主机发送消息

    // 接收响应
    char buffer[10240];
    int len = read(client_sock, buffer, sizeof(buffer) - 1);
    if (len == -1) {
        cerr << "Failed to receive response." << endl;
        return -1;
    }

    buffer[len] = '\0';//字符型的数组的长度可能设置的很大,用这个来截取有效部分,就可以直接cou
    cout << buffer << endl;

    // 关闭连接
    close(client_sock);
    return 0;
}

运行结果:

bind

在网络编程中,bind()函数用于将一个套接字(socket)与一个本地的IP地址和端口号绑定起来。在客户端程序中不常使用,但是在服务器端程序中,一般需要先创建一个套接字,然后将其绑定到一个固定的本地IP地址和端口号上,以便客户端可以通过这个地址和端口号与服务器进行通信。

bind()函数的函数原型如下:

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

其中,sockfd是已经创建好的套接字描述符,addr是一个指向本地IP地址和端口号的sockaddr类型的指针,addrlen是sockaddr类型的指针的长度。

bind()函数的返回值为0表示绑定成功,否则表示绑定失败。在调用bind()函数之前,需要先通过socket()函数创建一个套接字,并且需要在sockaddr结构体中指定本地IP地址和端口号,例如:

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 绑定到本地任意IP地址
server_addr.sin_port = htons(PORT);  // 绑定到指定端口号

接下来就可以调用bind()函数将套接字与本地IP地址和端口号绑定起来了,例如:

int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (ret == -1) {
    perror("bind error");
    exit(1);
}

详细例子:

#include<iostream>
#include<sys/socket.h>
#include<arpa/inet.h>//sockaddr_in
#include<cstring>//memset
using namespace std;

int main(){
    int sockfd=socket(AF_INET,SOCK_STREAM,0);
    if(sockfd==-1){
        cerr<<"failed to create socket"<<endl;
        exit(-1);
    }
    int PORT=2337;//设置端口2337,用没有被占用的就行
    struct sockaddr_in server_addr;
    memset(&server_addr,0,sizeof(server_addr));
    server_addr.sin_family=AF_INET;
    server_addr.sin_addr.s_addr=htonl(INADDR_ANY);//绑定到任意IP
    server_addr.sin_port=htons(PORT);//绑定到指定端口
    int ret=bind(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    if(ret==0){
        cout<<"succeed to bind PORT:"<<PORT<<endl;
    }else{
        cerr<<("bind error")<<endl;
        exit(1);
    }
}

listen

在网络编程中,listen()函数用于将一个套接字(socket)转换成一个监听套接字,以便于接受客户端的连接请求。在服务器端程序中,一般需要先创建一个套接字,然后将其绑定到一个固定的本地IP地址和端口号上,最后调用listen()函数将其转换成一个监听套接字,以便于接受客户端的连接请求。

listen()函数的函数原型如下:

int listen(int sockfd, int backlog);

其中,sockfd是已经创建好的套接字描述符,backlog是指定等待连接队列的最大长度。

listen()函数的返回值为0表示成功,否则表示失败。在调用listen()函数之前,需要先通过socket()函数创建一个套接字,并且需要通过bind()函数将其绑定到一个固定的本地IP地址和端口号上,接下来就可以调用listen()函数将套接字转换成一个监听套接字了,例如:

int backlog = 10;  // 等待连接队列的最大长度
int ret = listen(sockfd, backlog);  // 将套接字转换成监听套接字
if (ret == -1) {
    perror("listen error");
    exit(1);
}

调用listen()函数之后,套接字就会进入监听状态,等待客户端的连接请求。可以通过accept()函数来接受客户端的连接请求,并创建一个新的套接字用于与客户端进行通信。

给之前的程序添加listen:

#include<iostream>
#include<sys/socket.h>
#include<arpa/inet.h>//sockaddr_in
#include<cstring>//memset
using namespace std;

int main(){
    int sockfd=socket(AF_INET,SOCK_STREAM,0);
    if(sockfd==-1){
        cerr<<"failed to create socket"<<endl;
        exit(-1);
    }
    int PORT=2337;//设置端口2337,用没有被占用的就行
    struct sockaddr_in server_addr;
    memset(&server_addr,0,sizeof(server_addr));
    server_addr.sin_family=AF_INET;
    server_addr.sin_addr.s_addr=htonl(INADDR_ANY);//绑定到任意IP
    server_addr.sin_port=htons(PORT);//绑定到指定端口
//bind函数
    int bindret=bind(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    if(bindret==0){
        cout<<"succeed to bind PORT:"<<PORT<<endl;
    }else{
        cerr<<("bind error")<<endl;
        exit(-2);
    }
//listen函数
    int backlog=10;//最大连接队列长度
    int listenret=listen(sockfd,backlog);
    if(listenret==0){
        cout<<"turn to listening"<<endl;
    }else{
        cerr<<"failed to listen PORT"<<PORT<<endl;
        exit(-3);
    }
}

accept

socket的accept函数是用于等待并接受客户端连接请求的函数。当服务器端的socket处于listen状态时,可以调用accept函数来接受客户端的连接请求,并返回一个新的socket描述符,用于与客户端进行通信。

accept函数的语法如下:

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

其中,sockfd为服务器端的socket描述符,addr为指向用于存储客户端地址信息的结构体指针,addrlen为指向存储客户端地址信息长度的变量指针。其中socklen_t,这样的关键字一般都是由基本类型扩展过来的,在vs中go to definition,可以追溯到其实质就是usigned int类型。

因为返回的也是一个socket描述符,失败返回-1,成功返回描述符id

当accept函数被调用时,会阻塞等待客户端连接请求的到来。一旦有客户端连接请求到达,accept函数会返回一个新的socket描述符,用于与该客户端进行通信。同时,addr和addrlen参数也会被填充上客户端的地址信息。

需要注意的是,accept函数只有在服务器端socket处于listen状态时才能调用。而且,accept函数是一个阻塞函数,会一直等待直到有客户端连接请求到达。如果不希望accept函数一直阻塞,可以通过设置socket为非阻塞模式或设置超时时间等方式来避免阻塞

避免阻塞的方式
使用setsockopt函数
struct timeval timeout; 
timeout.tv_sec = 5;
timeout.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout, sizeof(timeout));

如果在5秒内没有收到任何数据,accept函数将返回一个错误码,并设置errno为EAGAIN或EWOULDBLOCK。可以根据这个错误码来判断是否超时。

if(apct==-1){
    cerr<<"connect configure error";
}else if(acpt==EAGAIN){
    cerr<<"timeout"<<endl;
}else{
    cout<<"connected"<<endl;
}

setsockopt函数是用来给套接字配置的函数,其定义如下:

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

其中,参数说明如下:

  • sockfd:指定需要设置选项的套接字描述符。
  • level:指定选项的协议层。常用的协议层有SOL_SOCKETIPPROTO_TCPSOL_SOCKET表示通用套接字选项,而IPPROTO_TCP表示TCP协议选项。
  • optname:指定需要设置的选项名称。
  • optval:指向存储选项值的缓冲区。
  • optlen:指定选项值的长度。

setsockopt函数的作用是用于设置套接字选项,常用的选项包括:

  • SO_REUSEADDR:表示允许地址重用,常用于服务器开启多次绑定同一端口的情况。
  • SO_KEEPALIVE:表示开启TCP的KeepAlive机制。
  • SO_SNDBUFSO_RCVBUF:分别表示发送缓冲区和接收缓冲区的大小。
  • TCP_NODELAY:表示禁用Nagle算法,即允许小数据包的发送。

需要注意的是,setsockopt函数必须在套接字创建后才能调用,且需要在进行任何IO操作之前设置

for more,refer to setsockopt | Microsoft Learn.

select函数
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

while (1) {
    fd_set read_fds;
    FD_ZERO(&read_fds);
    FD_SET(sock, &read_fds);

    struct timeval tv;
    tv.tv_sec = 1;
    tv.tv_usec = 0;

    int ret = select(sockfd + 1, &read_fds, NULL, NULL, &tv);
    if (ret == -1) {
        // select出错
        continue;
    } else if (ret == 0) {
        // 没有新连接
        continue;
    }

    int new_sock = accept(sockfd, (struct sockaddr *)&caddr, &len);
    if (new_sock == -1) {
        // accept出错
        continue;
    }

    // 处理新连接
}

select函数是Unix/Linux系统中的一个系统调用,在网络编程中常用于实现多路复用IO。它可以监听多个文件描述符,当其中任意一个文件描述符准备就绪时,就会通知程序进行相应的处理。

select函数的原型如下:

#include <sys/select.h>

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

参数说明:

  • nfds:需要检测的文件描述符数量,即文件描述符集合中所有文件描述符的最大值加1(因为文件描述符是从0开始编号的)。
  • readfds:可读文件描述符集合。
  • writefds:可写文件描述符集合。
  • exceptfds:异常文件描述符集合。
  • timeout:select函数的超时时间。如果设置为NULL,则表示等待直到有文件描述符准备就绪;如果设置为0,则立即返回;如果设置为一个非零值,则表示等待指定时间内有文件描述符准备就绪。

select函数的返回值为就绪文件描述符的数量,如果返回0,则表示超时未发生任何事件;如果返回-1,则表示select函数调用出错。

使用select函数,可以实现以下功能:

  • 监听多个文件描述符,实现多路复用IO。
  • 设置超时时间,避免程序一直阻塞在select函数调用处。
  • 监听不同类型的事件(可读、可写、异常),实现更加灵活的IO操作。
  • 在多线程编程中,可以使用select函数来实现线程间的通信。
使用fcntl
int sock = socket(AF_INET, SOCK_STREAM, 0);
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);

根据下面的解释,flags为sockfd描述符当前的文件状态标志,然后调用SETFL,设置描述符的文件状态标志,值设置为flags|O_NONBLOCK。应该是逻辑或操作。

但是这样设置后,调用accept会立即返回,没有等待时间,可以把accept放在循环中,等待client连接。

fcntl函数是一个Unix/Linux系统下的系统调用函数,用于对文件描述符进行操作。其原型如下:

#include <fcntl.h>
int fcntl(int fd, int cmd, ...);

fcntl函数的第一个参数fd是需要进行操作的文件描述符,第二个参数cmd是需要进行的操作指令,第三个可选参数为操作的附加参数。

fcntl函数的常用操作指令包括:

  • F_DUPFD:复制文件描述符,生成一个新的文件描述符;
  • F_GETFL:获取文件描述符当前的文件状态标志;
  • F_SETFL:设置文件描述符的文件状态标志;
  • F_GETLK:获取文件锁;
  • F_SETLK:设置文件锁;
  • F_SETLKW:设置文件锁,并等待文件锁被释放。

fcntl函数的使用场景包括:

  • 设置文件描述符的非阻塞模式;
  • 获取或设置文件描述符的文件状态标志;
  • 对文件进行加锁或解锁操作等。
使用epoll

epoll是Linux内核中的一种I/O事件通知机制,是高并发网络编程中常用的技术之一。epoll通过在内核中注册感兴趣的文件描述符集合,然后通过系统调用等待I/O事件的发生并通知应用程序。

与传统的select和poll相比,epoll具有更高的效率和可扩展性。这是由于epoll采用了基于事件驱动的方式,只有当文件描述符上有事件发生时才会通知应用程序,而不必遍历所有的文件描述符。此外,epoll支持ET(边缘触发)和LT(水平触发)两种工作模式,同时还支持一次性注册多个文件描述符,从而减少了系统调用的次数。

epoll的主要优点包括:

  1. 高效:能够处理大量并发连接,而不会因为轮询而导致CPU占用率过高。

  2. 可扩展:能够处理数以万计的并发连接,而且当连接数增加时,性能下降得非常缓慢。

  3. 能够处理任何类型的文件描述符:不仅可以处理网络套接字,还可以处理文件和管道等。

  4. 支持边缘触发和水平触发两种工作模式:边缘触发模式只在状态发生变化时才通知应用程序,而水平触发模式则在文件描述符上有数据可读时就通知应用程序,直到数据全部读取完毕。

边缘触发(edge trigger)和水平触发(level trigger)本来指脉冲信号的触发机制。水平指当脉冲信号持续水平时(高电平低电平都可以),就一直触发。边缘触发,也有说边沿触发,指只有出现上升沿或下降沿,也就是高电平转低电平这样的变化时,就触发一次。

边缘触发也泛指只在状态变化的瞬间触发一次事件,水平触发则泛指系统在事件状态保持的时候持续触发事件。

epoll socket编程:

使用epoll编写socket通常分为以下几个步骤:

  1. 创建socket:使用socket()函数创建一个socket描述符。

  2. 绑定socket:使用bind()函数将socket与IP地址和端口号绑定。

  3. 监听socket:使用listen()函数将socket设置为监听状态。

  4. 创建epoll实例:使用epoll_create()函数创建一个epoll实例。

  5. 将socket加入epoll监听队列:使用epoll_ctl()函数将socket添加到epoll监听队列中。

  6. 循环监听epoll事件:使用epoll_wait()函数循环监听epoll事件。

  7. 处理epoll事件:根据不同的事件类型,使用recv()函数接收客户端发送的数据,使用send()函数向客户端发送数据,或者使用accept()函数接收客户端的连接请求,并将新连接的socket加入epoll监听队列中。

  8. 关闭socket:使用close()函数关闭socket描述符。

//todo 就用epoll socket,两种模式,c2c,room

connect

connect 函数是用于建立与远程主机的连接的函数,通常在客户端程序中使用。下面是 connect 函数的详细介绍:

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

参数说明:

  • sockfd:已经创建好的套接字文件描述符。
  • addr:指向目标地址结构体的指针,该结构体包含目标IP地址和端口号等信息。
  • addrlenaddr 结构体的长度。
  • 返回值也是表示成功或失败的状态,不是新的套接字。

客户端连接服务端例子:

#include<iostream>
#include<sys/socket.h>
#include<arpa/inet.h>
using namespace std;
int main(){
    int servsock=socket(AF_INET,SOCK_STREAM,0);
    sockaddr_in servaddr;
    servaddr.sin_family=AF_INET;
    servaddr.sin_addr.s_addr=inet_addr("127.0.0.1");
    servaddr.sin_port=htons(2337);
    int con=connect(servsock,(sockaddr*)&servaddr,sizeof(servaddr));
    if(con==0){
        cout<<"connected to server"<<endl;
    }else{
        cerr<<"failed to connect"<<endl;
        exit(-2);
    }
}

send和recv函数

send

C++中的Socket库是基于BSD套接字接口的,因此其send函数与BSD套接字库中的send函数非常相似。send函数用于将数据发送到与Socket连接的远程主机,其语法如下:

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

其中,sockfd参数是Socket描述符,buf参数是要发送的数据缓冲区指针,len参数是要发送的数据长度,flags参数是可选的,用于指定发送数据的选项,例如发送数据时是否使用带外数据等。

send函数的返回值是已经成功发送的数据的字节数。如果发送失败,则会返回-1,并设置errno变量指示失败的原因。在发送数据之前,应该先建立好Socket连接,否则send函数会失败。

send函数的工作原理是将数据缓存在内核中,直到缓冲区满或者超时时间到达才会将数据发送出去。如果数据太大,超过了缓冲区的大小,则会被分成多个数据包进行发送。

需要注意的是,send函数不保证所有数据都会立即发送成功,因此需要在发送数据之后进行检查确认。如果需要保证数据的可靠传输,则可以使用TCP协议,它会自动处理数据的可靠性。

recv

Socket库中的recv函数是用于接收数据的函数,其函数原型如下:

#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

recv函数的四个参数含义如下:

  • sockfd:指定要接收数据的Socket描述符。
  • buf:指定接收数据的缓冲区地址。
  • len:指定接收数据的最大长度。
  • flags:指定接收数据的标志位,常用的标志位有MSGDONTWAIT、MSGOOB等。

recv函数的返回值为接收到的数据长度,如果返回值为0,则表示对端已经关闭连接,如果返回值为-1,则表示发生错误。在发生错误时,errno变量会被设置为相应的错误码,程序员可以通过检查errno变量来判断错误的原因。

下面是recv函数的工作流程:

  1. 应用程序调用recv函数,指定要接收数据的Socket描述符、接收数据的缓冲区地址、接收数据的最大长度和接收数据的标志位。

  2. 操作系统内核接收到应用程序的请求后,开始等待数据到达。如果数据已经到达,则将数据读取到内核中的接收缓冲区。

  3. 如果接收缓冲区中没有数据,则recv函数会阻塞等待,直到有数据到达为止。如果设置了MSG_DONTWAIT标志,则recv函数会立即返回,不会阻塞等待。

  4. 一旦有数据到达,操作系统内核会将数据从接收缓冲区复制到应用程序指定的接收缓冲区中,并返回实际接收到的数据长度。

  5. 应用程序可以继续调用recv函数接收剩余的数据,直到接收完所有数据为止。

需要注意的是,在使用recv函数接收数据时,需要根据实际情况判断接收到的数据是否完整,如果数据不完整需要继续接收,直到接收到完整的数据为止。另外,为了避免发生死锁,应该在调用recv函数之前先调用select或poll等函数进行检查,以确保接收缓冲区中有数据可读。

epoll编程

参考高并发网络编程之epoll详解.tcp并发服务器(epoll实现).辅以ChatGPT

在Linux实现epoll之前,IO多路复用一般使用select或者poll,实现的即使就是遍历轮询。但效率低,开销大。

select的缺点:

  1. 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE    1024)
  2. 内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销;
  3. select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
  4. select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。

poll使用链表保存文件描述符,虽然没有了监视文件数量的限制,但select的其他三个缺陷依然存在。

而epoll实现了不同的机制,不再是轮询,而是触发。只有当监听的文件描述符发生变化时,才会处理,否则就一直阻塞。这就是epoll的边缘触发模式(edge trigger)。

在epoll中,有三个主要的函数:epollcreate、epollctl和epoll_wait。

  1. epoll_create

epoll_create函数用于创建一个epoll实例,返回一个文件描述符。它的原型如下:

int epoll_create(int size);

参数size指定了需要管理的文件描述符的个数,但是这个参数在Linux 2.6.8及以后版本被忽略了,因此通常设为0即可。

  1. epoll_ctl

epoll_ctl函数用于向epoll实例中添加或删除文件描述符,并设置相应的事件类型。它的原型如下:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数epfd是epoll实例的文件描述符,参数op指定了要进行的操作,包括:

  • EPOLLCTLADD:向epoll实例中添加文件描述符,并设置相应的事件类型;
  • EPOLLCTLMOD:修改epoll实例中已有的文件描述符的事件类型;
  • EPOLLCTLDEL:从epoll实例中删除文件描述符。

参数fd是需要添加、修改或删除的文件描述符,参数event是一个epoll_event结构体,用于设置事件类型和数据。

如:将一个socket添加到epoll实例。

#include<sys/epoll.h>//epoll
#include<sys/socket.h>
int main(){
    int sockfd=socket(AF_INET,SOCK_STREAM,0);
    struct epoll_event event;//事件结构体
    event.events=EPOLLIN;
    event.data.fd=listen_fd;
    int epollfd=epoll_create(0);
    epoll_ctl(epollfd,EPOLL_CTL_ADD,sockfd,&event);
}
  1. epoll_wait

epoll_wait函数用于等待文件描述符上的事件,它会一直阻塞,直到有事件发生或超时。它的原型如下:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数epfd是epoll实例的文件描述符,参数events是一个epoll_event结构体数组,用于存储事件,参数maxevents指定了最多可以返回的事件个数,参数timeout指定了超时时间,如果为-1,则表示一直阻塞,直到有事件发生。

在epoll_wait函数返回时,会将事件存储在events数组中,并返回事件的个数。每个事件包含了文件描述符和相应的事件类型。

详例
#include "server.h"

int main(){
    int server_socket=socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(sockaddr_in));
    server_addr.sin_family=AF_INET;
    server_addr.sin_port=htons(SERVER_PORT);
    server_addr.sin_addr.s_addr=INADDR_ANY;
    if(bind(server_socket,(sockaddr *)&server_addr,sizeof(server_addr))<0){
        cerr<<"chat_server: main: server bind error"<<endl;
        exit(-1);
    }
    if(listen(server_socket,10)<0){
        cerr<<"chat_server: main: server listen error"<<endl;
        exit(-1);        
    }
    int epoll_fd=epoll_create(1);
    epoll_event socket_event,listen_event[MAX_LISTEN];
    socket_event.events=EPOLLIN; //TODO  LT/ET?  //高版本没有EPOLLLT,默认水平触发,一旦发现客户端的连接请求就持续建立连接
    socket_event.data.fd=server_socket;
    epoll_ctl(epoll_fd,EPOLL_CTL_ADD,server_socket,&socket_event);
    while(1){
        int event_num=epoll_wait(epoll_fd,listen_event,MAX_LISTEN,-1); 
        if(event_num<-1){                               
            break;  //无连接则继续循环等待                                    
        }
        for(int i=0;i<event_num;i++){   //遍历返回事件
            if(listen_event[i].data.fd==server_socket){ //如果是server socket,说明有客户端发起连接请求,就建立新的连接
                sockaddr_in client_addr;
                socklen_t clinet_size=sizeof(sockaddr_in);
                int client_socket=accept(server_socket,(sockaddr *)&client_addr,&clinet_size);
                if(client_socket<0){    //连接建立失败,则跳过重连
                    continue;
                }else{
                    cout<<client_addr.sin_addr.s_addr<<":"<<client_addr.sin_port<<" connected"<<endl;
                }
                socket_event.events=EPOLLIN | EPOLLET;    //EPOLLET设置为ET模式
                socket_event.data.fd=client_socket;
                epoll_ctl(epoll_fd,EPOLL_CTL_ADD,client_socket,&socket_event);  //将获取到的新连接加入到epoll实例中
            }else{//如果不是fd,说明是客户端发送了数据
                int session_socket=listen_event[i].data.fd;     //获取连接,建立通信
                char *buff;
                int ret = recv(session_socket,buff,2048,0); //非阻塞如果没有数据那么就返回-1
                cout<<buff<<endl;
                }
            }
        }
}

这就是一个简单的epoll tcp服务器,它能够以触发的机制来访问活动事件的描述符,虽然使用起来相比select复杂,但是它的效率更高。

SOCKET的本质

fd

linux上socket的本质是一个fd(file descriptor)文件,它是由linux内核动态创建、销毁的。所以,socket文件并不是普通的磁盘文件,无法通过传统路径访问,实际上,它是一个指向进程已打开的文件、设备或 Socket 的引用。每个进程启动时,都会分配三个标准的 fd 文件,这些文件对应于 stdinstdout 和 stderr。除此之外,每个进程还可以创建任意数量的自定义 fd 文件,这些文件可以对应于打开的磁盘文件、管道、Socket 等。

我们可以使用readlink来查看fd引用的原文件,比如:

edge浏览器的一个crashpad进程,PID为74103

可以使用readlink查看具体的引用文件

readlink /proc/74103/fd/fdnumber

如图,3描述符的引用是一个socket,5描述符是一个dat文件,6描述符是一个bin文件。

客户端socket

而我们的tcp服务器,在有客户端连接时,也会动态创建fd描述符

如图我们的server_main的PID为72539,查看其fd

ls /proc/72539/fd 

服务端已经占用了0-4描述符,当客户端连接的时候,服务的进程创建fd5,并读取缓存,最后销毁fd5。所以直接readlink 5是空的,因为事件已经结束了,我们可以通过循环读取来查看:

#! /bin/bash

while :
do
        readlink /proc/72539/fd/5 >> ./socket.log
done

执行shell脚本,并用客户端连接服务器,查看socket.log:

这个就是客户端连接服务器时创建的socket文件。

posted @ 2023-06-03 00:02  Tenerome  阅读(2)  评论(0编辑  收藏  举报
回帖
    张三

    张三 (王者 段位)

    1111 积分 (2)粉丝 (41)源码

     

    温馨提示

    亦奇源码

    最新会员