tcp&ip网络编程笔记

131 阅读24分钟

前言

本书适合网络编程基础较弱,对网络编程感兴趣的读者。本书将从Linux和Windows两个平台讲解网络编程。本人是计算机非科班出身,在看一些中间件架构时如redis,netty,由于其网络线程模型经常出现IO多路复用这个名词,又查了一下IO多路复用有3种实现方式select,poll,epoll,里面还涉及到Socket编程啥的,看了一下这一块属于网络编程的范畴,遂即找了一本网络编程入门书籍。以下我会选几章我认为重要的或有用的章节记录笔记,本文只会选取Linux平台和tcp相关的内容。

第一章

计算机之间网络数据的传输除了需要硬件部分,软件部分则是由OS提供的**套接字(socket,直译为插座,网络连接,就像插口和插座一样,一方插,一方被插)**部件实现的。在Linux中,socket也被视为文件的一种,对文件的IO操作函数都可用于socket。

文件描述符:在Linux中,每当OS生成文件或socket时,会返回分配给他们的整数,类似于一种OS赋予的别名,主要是方便程序员和OS沟通,在Windows中也叫句柄。

服务端构建套接字传输数据API:

socket()函数,创建socket

bind()函数,给套接字绑定一个地址(IP和端口)

listen()函数,转为可接收状态,准备好接收客户端的连接请求

accept()函数,通过listen函数监听到有连接请求来了,然后用此函数处理客户端的连接请求,如没有连接请求则会阻塞直至有为止

客户端构建套接字传输数据API:

socket()函数,创建socket

connect()函数,向服务端发送连接请求

第二章

本章以socket的创建函数socket(int domain, int type, int protocol )的几个入参出发讲解socket的类型(type)及协议(protocol)的设置,domain表示socket中用的协议族(Protocol Family)

概念:


协议:为了完成数据交互而定好的约定

协议族:协议分类信息,协议所属分类

image-20240319112553498

socket()中的第一个参数决定第三个参数

套接字类型:指的是套接字的数据传输方式,常用是SOCK_STREAM,SOCK_DGRAM

  • SOCK_STREAM(面向连接的套接字):
    • 可靠的:
      1. 套接字内部有字节数组构成的缓冲,若缓冲中写的速度比读的速度快,则缓冲区会有满数据状态,这时套接字将停止传输数据,所以后续的数据不会继续传输以免发送数据丢失。
      2. 传输出错,套接字提供重传服务
    • 按序传输数据:数据传输是有序的
    • 传输的数据不存在数据边界:数据进入缓冲二进制字节数组,传过来的数据可以先不读,储存在缓冲中一次被读走,也可多次调用读函数读走。所以接收数据的次数和传输的次数可以不同。有粘包和拆包问题。
  • SOCK_DGRAM(面向消息的套接字):
    • 不可靠
    • 强调快速传输,不按序传输
    • 传输数据有数据边界:在报头里面加入了边界信息,没有粘包,拆包问题
    • 限制每次传输的数据大小

第三个参数协议的最终选择:

socket()中的三个参数可以确定创建的socket使用的传输协议,通常靠前两个参数即协议族和套接字类型(数据传输方式)就可确定协议了,第三个参数协议为0即可。

但如果前2个参数无法确定第三个参数时就要指定协议信息了。

创建TCP套接字:socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)

创建UDP套接字:socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP)

第三章

本章将从bind()函数(分配给套接字IP和端口的API)中出发讲解其参数所涉及的相关内容,地址族等。

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

sockfd是被分配的socket,addr是指向sockaddr结构体(包含要绑定的地址信息,包括 IP 地址和端口号)指针,addrlen 是addr结构体的长度

IP 之网络地址

  • 概念:为使计算机连接到网络并收发数据,要向其分配IP地址,IP地址一般分为IPv4(Internet Protocol version 4,4字节地址族)和IPv6(Internet Protocol version 6,16字节地址族),现在主流还是IPv4,而IPv4的4字节又由网络地址和主机地址组成。例如:向www.lg.com 传输数据,203.211.217.202是目标计算机,它会先到其网络地址203.211.217 , 然后通过路由或交换机找到局域网中主机地址为202的计算机。

端口号

  • 概念:IP用于区分不同的计算机,端口号则可用于区分一台计算机中的多个套接字。例如:我们在浏览器打开视频的同时还网上冲浪,这时需要一个socket来接收视频数据以及另外一个socket来接收网络数据,想要区分这2个socket,就要用到端口号。
    • 计算机中的NIC(网卡)是用来数据传输的设备,外部数据通过NIC传输进来,这些数据携带有端口和IP信息,OS再参考这个端口信息把数据传给对应端口的socket
    • 端口号由16位构成,端口号范围是0-65535,但0-1023 是知名端口 ( Wel1-known PORT 般分配给特定应用程序,所以应当分配此范围之外的值
    • 端 口号不能重复,但TCP套接字和UDP套接字不会共用端口号,所以允许重复 例如:如果某TCP 套接字使用9190号端口,则其他TCP套接字就无法使用该端口号,但UDP套接字可以使用

地址信息

有了上面的铺垫,现在可以讲讲bind()函数的第二个参数了即sockaddr结构体,它包含要绑定的地址信息,包括 IP 地址和端口号。

struct sockaddr_in {
    short            sin_family;   // 地址族,通常设置为 AF_INET(IPv4使用的地址族,其它如AF_INET6就是IPv6使用的地址族)
    unsigned short   sin_port;     // 端口号,网络字节序
    struct in_addr   sin_addr;     // 32位IPv4 地址结构体
    char             sin_zero[8];  // 无特殊含义,只是为使结构体sockaddr_in 的大小与sockaddr结-构体保持一致而插入的成员需填充为 ,否则无法得到想要的结果
};

struct in_addr {
    unsigned long s_addr;  // 32位IPv4 地址,网络字节序
};

实际bind()函数要的是sockaddr结构体,是一个通用的套接字地址结构体,用于在网络编程中表示各种类型的地址信息。

struct sockaddr {
    unsigned short    sa_family;    // 地址族,通常设置为 AF_INET、AF_INET6 等
    char              sa_data[14];  // 地址数据,可以存储地址信息
};	

此结构体成员 sa data保存的地址信息中需包含 地址和端口号,剩余部分应填充 ,这也是 bind 函数要求的 而这对于包含地址信息来讲非常麻烦 继而就有了新的结构体sockaddr in 按照之前的讲解填 sockaddr in结构体,则将生成符合bind 函数要求的字节流 最后转换为 sockaddr 的结构体变 ,再传递给bind()函数即可。

网络字节序

不同CPU中, 4字节整数型值在内存空间的保存方式是不同的。4字节整数型值 可用2进制 表示如下。

00000000 00000000 00000000 00000001

但有些cpu则以倒序保存

00000001 00000000 00000000 00000000

若不考虑这些就收发数据则会发送问题,因为保存顺序的不同意味着对接收数据的解析顺序也不同。

网络字节序之字节序

CPU 向内存保存数据的方式有2种,这 味着CPU解析数据的方式也分为2种

  • 大端序:高位字节存放到低位地址
  • 小端序:高位字节存放到高位地址

image-20240320111625415

image-20240320111742258

网络字节序之字节序转换

htons():将16位(2字节)的主机字节序转换为网络字节序。

htonl():将32位(4字节)的主机字节序转换为网络字节序。

ntohs():将16位(2字节)的网络字节序转换为主机字节序。

ntohl():将32位(4字节)的网络字节序转换为主机字节序。

h:host,主机字节序

n:network,网络字节序

s:short ,Linux中short占用2字节

l:long,Linux中long类型占4字节

ntohs可以解释为:将short型(2字节)数据从网络字节序转为主机字节序

网络地址的初始化与分配:

inet_addr()函数:

sockadd_in 中保存地址信息自的成员为32位整数型 ,inet_addr()函数可以将字符串形式的IP地址转为32位整数型数据,且转换时同时进行了网络字节序转换。

inet_aton()函数:

功能与inet_addr()函数一样,实际 编程 若要调用inet_addr函数, 将转换后的地址信息代入sockaddr_in 结构体中声明 in_addr 结构体变量 inet_aton 函数则不需此过程 原因在于,若传递 addr结构 变量地址 值,函数会自动把结果填入该结构体变 量。

inet_ntoa()函数:

该函数将通过参数传人的整数型IP地址转换为字符串格式并返回

第四章

本章将会介绍TCP/IP协议栈、listen()函数及accept()函数

TCP:

socket()函数中第二个参数是指定数据传输方式,根据数据传输方式的不同,基于网络协议的套接字一般分为TCP套接字和UDP套接字 因为 TCP套接字是面向连接的,因此又称基于流 (stream )的套接字 TCP Transmission Control Protocol (传输控制协议)的简写,意为"对数据传输过程的控制" .

4.1 TCP/IP 协议栈

TCP/IP协议钱共分4层,可以理解为数据收发分成了4个层次化过程 。面对"基于互联网的有效数据传输"的命题,并非通过 个庞大协议解决问题,而是化 整为零,通过层次化方案一-TCP/IP协议栈解决。通过TCP套接字收发数据时需要借助这4层。

image-20240320135940009

4.1.1 链路层

链路层是物理链接领域标准化的结果,也是 最基本的领域,专门定义LAN WAN MAN等 网络标准 ,两台计算机要进行数据交换,要进行物理连接,物理设备如交换机,路由器等连接标准就是链路层负责的。

4.1.2 IP层

链路层准备好物理连接后,就可以传输数据了,而IP层就负责传输数据的路径问题。不过IP本身是面向消息、不可靠的协议。若是数据传输过程中出现数据丢失或错误就无法解决了。

4.1.3 TCP/UDP 层(传输层)

IP层准备好路径后,TCP/UDP层以IP层提 供的路径信息为基础完成实际的数据传输,故该层又称传输层( Transport ). IP层数据的传输顺序及传输本身不可靠,TCP则赋予了IP可靠性。

4.1.4 应用层

程序员用socket进行网络编程时,以上几层的通信过程是自动处理的,这些处理过程都被隐藏进socket内部。编写软件的过程 中, 需要根据程序特点决定服务器端和客户端之间的数据传输规则(规定),这便是应用层协议。网络编程的大部分内容就是设计并实现应用层协议

4.2 listen()函数

image-20240320143336013

前面几章介绍了socket()、bind()函数,现在来看看listen()函数

// sock是服务端socket的fd, 是监听套接字
// backlog是连接请求等待队列( Queue )的长度,若为 5,则队列长度为5 ,表示最多使5个连接请求进入队列
int listen(int sock, int backlog);

image-20240320145435787

我们只有调用了listen 函数,客户端才能进入可发出连接请求的状态。换言之,这时客户端才能 调用connect 函数(若提前调用将发生错误)。客户端如果向服务器端询问 "请问我是否可以发起连接?"服务器端套接字就会亲切应答 "您好! 当然可以,但系统 忙,请到等候室排号等待,准备好后会立即受理您的连接 "同时将 连接请求请到等候室。调用listen函数即可生成这种门卫(服务器端套接字), listen 函数的第二个 参数决定了等候室的大小。等候室称为连接请求等待队列,准备好服务器端套接字和连接请求等 待队列后,这种可接收连接请求的状态称为等待连接请求状态。

4.3 accept()函数

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

sockfd:服务端socket的fd

addr:用于存储客户端的地址信息,调用该函数后,addr将会被赋值

addrlen:用于指定 addr 结构体的长度。

accept 函数受理**连接请求等待队列(listen()函数所产生的大小为backlog的队列)**中待处理的客户端连接请求 函数调用成功时, accept 函数 内部将产生用于数据I/O 的套接字, 返回其文件描述符。需要强调的是,套接字是自动创建的, 并自动与发起连接请求的客户端建立连接。

4.4 客户端套接字地址信息

客户端建立连接只要socket()函数和connect()函数看似没有给socket分配地址。

客户端的 IP地址和端口在调用 connect 函数时自动分配,无需调用标记的bind 函数进 行分配。IP用计算机(主机)的IP ,端口随机。

第五章

本章我主要记录了TCP内部原理相关笔记

TCP套接字中的I/O缓冲

  • IO缓冲在每个TCP套接字中单独存在
  • IO缓冲在创建套接字时自动生成
  • 即使关闭套接字也会继续传递输出缓冲中遗留的数据
  • 关闭套接字将丢失输入缓冲中的数据
  • TCP套接字不会出现缓冲溢出(即写入数据大于读缓冲区大小)因为TCP会控制数据流,具体实现为滑动窗口协议。

TCP套接字的连接(三次握手)

主机A为发起端,主机B为被连接端

第一次握手:主机A发送SYN信息

  • 主机A发送消息【SEQ: 1000, ACK: -】
  • SEQ1000意为:主机A 向B传输序号为1000的数据包 ,如接收无误请通知我传递1001号数据包
  • SYN是Synchronization的简写, 表示收发数据 前传输的同步消息 ,也表示首次请求连接使用的消息

第二次握手:主机B发送SYN+ACK消息

  • 主机B发送消息【SEQ: 2000, ACK: 1001】
  • SEQ2000意为:主机B向A传输序号为2000的数据包 ,如接收无误请通知我传递2001号数据包
  • ACK1001意为:主机A刚才传输的SEQ 1000 的数据包接收无误,现在请传递SEQ 1001 的数据包

第三次握手:主机A发送ACK消息

  • 主机A发送消息【SEQ: 1001, ACK: 2001】

  • SEQ1001意为:主机A向B传输序号为1001的数据包

  • ACK2001意为:主机B刚才传输的SEQ 2000 的数据包接收无误,现在请传递SEQ 2001 的数据包

TCP套接字的数据交换

image-20240321160545947

如图主机A向主机B一共传输了200字节数据,但每次的ack号不是+1而是基于数据包大小而累加的再+1,这样可以明确传输的数据是全部被正确地传递了还是丢失了部分比如只传送80字节。

例如:主机A 发送消息SEQ 1200,数据大小为100,主机B接收并发送ack1301,然而这时主机A并没收到ack1301,此时若超时了则触发TCP的重传

TCP套接字的关闭(四次挥手)

image-20240321162028901

  • 第一次挥手 客户端发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,其序列号为seq=5000,此时,客户端进入FIN-WAIT-1(终止等待1)状态
  • 第二次挥手 服务器端接收到连接释放报文后,发出确认报文,ACK=5001,并且带上自己的序列号seq=7500,此时,服务端就进入了CLOSE-WAIT 关闭等待状态,此时的TCP处于半关闭状态
  • 第三次挥手 服务器也打算断开连接,就向客户端发送连接释放报文,服务器就进入了LAST-ACK(最后确认)状态,等待客户端的确认。
  • 第四次挥手 客户端收到服务器的连接释放报文后,必须发出确认,ACK=5001,而自己的序列号是seq=7502。此时,客户端就进入了TIME-WAIT(时间等待)状态,但此时TCP连接还未终止,必须要经过2MSL后(最长报文寿命),当客户端撤销相应的TCP后,客户端才会进入CLOSED关闭状态,服务器端接收到确认报文后,会立即进入CLOSED关闭状态,到这里TCP连接就断开了,四次挥手完成

为什么客户端要等待2MSL? 主要原因是为了保证客户端发送那个的第一个ACK报文能到到服务器,因为这个ACK报文可能丢失,并且2MSL是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃,这样新的连接中不会出现旧连接的请求报文。

为什么挥手需要四次?

这是由于TCP的半关闭(half-close)造成的。半关闭是指:TCP提供了连接的一方在结束它的发送后还能接受来自另一端数据的能力。通俗来说,就是不能发送数据,但是还可以接受数据。

TCP不允许连接处于半打开状态时,就单向传输数据,因此完成三次握手后才可以传输数据(第三握手可以携带数据)。

当连接处于半关闭状态时,TCP是允许单向传输数据的,也就是说服务器此时仍然可以向客户端发送数据,等服务器不再发送数据时,才会发送FIN报文段,同意现在关闭连接。

这一特性是由于TCP双向通道互相独立所导致的,也使得关闭连接必须经过四次握手。

可能有些人会有疑惑:为什么中间的ACK和FIN不可以像三次握手那样合为一个报文段呢?

在socket网络编程中,执行close()方法会触发内核发送FIN报文。什么时候调用close()方法,这是由用户态决定的,假如服务器仍有大量数据等待处理,那么服务器会等数据处理完后,才调用close()方法,这个时间可能会很久,而ACK报文则是由系统内核来完成的,这个过程会很快。所以中间的ACK和FIN不能合为一个包。

第七章

之前我们用close()实现单方面的完全断开连接,但仍存在问题,本章讲解优雅断开套接字连接之半关闭。

单方面的完全断开连接的问题

完全断开意味着无法传输数据也不能接收数据。例如:主机a在给主机b发送一条消息后调用close(),那么主机a再无法收到来自主机b传输的且主机a必须接收的数据。为此,半关闭(只关闭一部分数据交换中使用的流)应运而生。具体指的就是主机可以传输数据而无法接收或只能接收而无法传输数据,也就是只关闭流的一半。

套接字与流

2台主机建立套接字后可交换数据的状态可看作流。流可比作水流,朝着一个方向流动。所以建立套接字后建立双向通信需要两个流。一台主机的输入流和另一台主机的输出流相连,一台主机的输出流与另一台主机的输入流相连。close()会同时断开2个流。

优雅断开函数之shutdown

int shutdown(int sockfd, int how);

  • sockfd:要关闭的套接字的文件描述符。

  •   how:指定关闭方式的整数参数,可以是以下值之一:
    
    • SHUT_RD:关闭套接字的读功能,禁止接收数据,输入缓冲含有数据也会销毁。
    • SHUT_WR:关闭套接字的写功能,禁止发送数据,输出缓冲若还有数据未传输,会将其传输出去。
    • SHUT_RDWR:关闭套接字的读和写功能,禁止接收和发送数据。这相当于分2次调用shutdown 其中一次以SHUT_RD 为参数,另一次以SHUT_WR为参数

半关闭适用于哪些场景

  1. 当然适用于那些不需要长久保持通信状态的一站式服务;
  2. 特别适用于HTTP通信,而且特别适用于客户端,因为客户端在发送请求后就无需再发送其它数据了,往往只是等待服务器端返回想要的资源;
  3. 因此在客户端发送完请求之后就可以立马半关闭输出流了(半写状态);

第九章

套接字具有多种特性,本章介绍这些特性和开启特性的函数及选项。

开启套接字的对应特性的函数

  • int **getsockopt ** (int sockfd, int level, int optname, void *optval, socklen_t *optlen); getsockopt() 是一个用于获取套接字选项的函数
    • sockfd:指定要获取选项的套接字文件描述符。
    • level:指定选项所在的协议层。常见的有 SOL_SOCKET(通用套接字选项)和 IPPROTO_TCP(TCP特定选项)等。
    • optname:指定要获取的选项名,如 SO_REUSEADDRSO_KEEPALIVE 等。
    • optval:指向存放选项值的缓冲区的指针。
    • optlen:指向存放选项值长度的变量的指针,调用函数时会将实际选项值长度写入该变量中。
  • int setsockopt (int sockfd, int level, int optname, const void *optval, socklen_t optlen); setsockopt()是用于设置套接字选项的函数
    • sockfd:指定要设置选项的套接字文件描述符。
    • level:指定选项所在的协议层。常见的有 SOL_SOCKET(通用套接字选项)和 IPPROTO_TCP(TCP特定选项)等。
    • optname:指定要设置的选项名,如 SO_REUSEADDRSO_KEEPALIVE 等。
    • optval:指向包含要设置的选项值的缓冲区的指针。
    • optlen:指定选项值的长度。

通用套接字选项


  • SO_RCVBUF:设置接收缓冲区大小。

  • SO_SNDBUF:设置发送缓冲区大小。

  • SO_REUSEADDR:允许重用处于 TIME_WAIT 状态的套接字地址。

    • image-20240322105256974
    **什么是TIME_WAIT** 
    在网络编程中,TIME_WAIT 是 TCP 状态之一,用于确保在连接关闭后,双方交换的最后一个 ACK 包能够被正确接收。在 TCP 连接关闭的过程中,一般会经历以下状态:
    
    FIN_WAIT_1:表示本端已经发送了连接释放报文段(FIN),等待对方的确认。
    FIN_WAIT_2:表示本端已经收到了对方的确认,等待对方发送连接释放报文段。
    TIME_WAIT:表示连接已经关闭,本端等待 2MSL(Maximum Segment Lifetime)时间后才能关闭套接字。2MSL 是为了确保在网络中所有报文段都被丢弃,避免新旧连接混淆。
    
    
    **为什么要有TIME_WAIT?**
    1.确保最后一个 ACK 被对方正确接收,避免出现数据包丢失导致的连接混乱。
    以上图为例,若主机A在发送最后一条数据时,也就是ACK SEQ 5001 ACK7502,若这最后一条ACK没被主机B收到,此时主机A并没完全关闭所以可以重发ACK,从而实现两方正确关闭。
    2.防止出现旧的重复数据包被新连接误认为是有效数据。
    3.允许之前连接的残留数据包在网络中消失,避免对后续连接造成干扰。
    在 TIME_WAIT 状态结束后,套接字才会真正关闭,资源被释放。TIME_WAIT 状态的持续时间通常为 2 倍的最大报文段生存时间(2MSL),MSL 是一个 IP 数据包在网络中最长存活的时间。TIME_WAIT 状态的存在是 TCP 协议设计的一部分,用于保证网络连接的可靠性和正确性。
    

    所以若主机A的socket1正处于TIME_WAIT状态,此时主机A再新创一个socket2调用bind()绑定端口地址,若现在绑定socket2的端口为socket1的端口,这时就会出现绑定错误,因为socket1端口号还在占用。要想立即用这个地址,就可以配置SO_REUSEADDR选项参数,这样就可以把正处于TIME_WAIT状态下套接字端口号重新分配给新的套接字。

​ SO_REUSEADDR适用场景:

  1. 快速重启服务:当服务需要在短时间内重启,并且需要使用相同端口时,SO_REUSEADDR 可以确保新服务能够立即启动,而不必等待 TIME_WAIT 状态结束。
  2. 负载均衡:在负载均衡场景下,多个实例可能需要监听相同的端口。使用 SO_REUSEADDR 可以简化配置和管理,同时确保所有实例都能够监听同一端口。
  3. 高可用性系统:在高可用性系统中,可能会有备用实例需要接管主实例的工作。通过 SO_REUSEADDR,备用实例可以快速接管主实例的端口,实现快速切换。
  4. 开发和调试:在开发和调试过程中,可能需要频繁启动和停止服务。使用 SO_REUSEADDR 可以简化开发过程,避免端口占用导致的问题。

TCP 套接字选项


TCP_NODELAY:

Nagle算法:

image-20240322112709140

上图展示了使用Nagle算法时,只有收到前一数据的ack后,才会发送下一数据。

tcp默认开启了Nagel,能最大限度使用缓冲。若不使用Nagel,发送过程与ack接收与否无关,数据到达缓冲后就立即被发送出去了,如图右所示,它会把数据更多次传输,这样会加大网络负担,若每次只发送一个字节数据,其头信息有可能为几十个字节,所以效率不高。

禁用Nagle算法:

在某些场景下,适合禁用Nagle,比如大文件传输的场景,大文件由于数据够大,所以被划分成多个数据包,且数据包每次都会立即填满缓冲,这时禁用Nagel算法,反而无需等待ACK而连续传输,大大提高了传输速度。

禁用方式:将套接字可选项TP_NODELAY改为 1 即可

第十一章

本章主要讲了进程间的通信

通过管道实现进程间通信

  • 管道是属于OS的,两个进程通过OS的提供的内存空间(管道)进行通信。

  • 管道是半双工的,数据只能在一个方向上流动。

  • 管道有固定的缓冲区大小,一旦缓冲区满了,写入进程会被阻塞。

  • 管道通常用于父子进程之间的通信,父进程创建管道后,可以 fork() 一个子进程,父子进程就可以通过管道进行通信。

  • 管道是一种匿名的通信机制,只能在有亲缘关系的进程之间使用。

创建pipe的函数

int pipe(int pipefd[2]);

  • pipefd 是一个整型数组,用于存放返回的两个文件描述符,pipefd[0] 用于读取,pipefd[1] 用于写入。
  • pipe() 调用成功返回 0,失败返回 -1。