TCP/IP协议族标准只规定了网络各个层次的设计和规范,具体实现则需要由各个操作系统厂商完成。最出名的网络库由BSD 4.2版本最先推出,所以称作伯克利套接字,这些API随后被移植到各大操作系统中,并成为了网络编程的事实标准。 socket 即套接字是指网络中一种用来建立连接、网络通信的设备,用户创建了 socket 之后,可以通过其发起或者接受 TCP 连接、可以向 TCP 的发送和接收缓冲区当中读写TCP数据段,或者发送 UDP 文本。
**struct sockaddr 和 struct sockaddr_in
**
我们主要以IPv4为例介绍网络的地址结构。主要涉及的结构体有 struct in_addr 、 struct sockaddr 、 struct sockaddr_in 。其中 struct sockaddr 是一种通用的地址结构,它可以描述一个 IPv4或者IPv6的结构,所有涉及到地址的接口都使用了该类型的参数,但是过于通用的结果是直接用它 来描述一个具体的IP地址和端口号十分困难。
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
大小端(Endianness :是指多个字节的数据在内存中的存储方式。在计算机系统中,一个多字节的数据类型会被分为若干个字节来存储,而这些字节的存放顺序就称为大小端。
大端法(Big-endian):是指数据的高位字节存储在内存的低地址处,而数据的低位字节存储在内存的高地址处。例如,在十六进制表示下,整数0x12345678在内存中的存储顺序为0x12 0x34 0x56 0x78。这种存储方式通常用于网络协议和一些处理器架构中,如Motorola 68000和IBM PowerPC等。
小端法(Little-endian):则是指数据的低位字节存储在内存的低地址处,而数据的高位字节存储在内存的高地址处。例如,在十六进制表示下,整数0x12345678在内存中的存储顺序为0x78 0x56 0x34 0x12。这种存储方式通常用于微处理器和操作系统中,如x86架构。
对于不同大小端的计算机之间进行数据交换时,需要进行大小端转换。具体来说,将一个多字节的数据类型拆分成单个字节后,按照相反的顺序重新组合即可完成大小端转换。
在C语言中,可以使用以下两个函数进行大小端转换操作:
htons()和htonl()函数:这两个函数可将16位或32位无符号整数从主机字节序转换为网络字节序,其中htons()用于转换16位整数,htonl()用于转换32位整数。如果本机字节序和网络字节序相同,则不会进行任何转换操作。
ntohs()和ntohl()函数:这两个函数可将16位或32位无符号整数从网络字节序转换为主机字节序,其中ntohs()用于转换16位整数,ntohl()用于转换32位整数。如果本机字节序和网络字节序相同,则不会进行任何转换操作。
需要注意的是,以上四个函数都需要包含头文件
域名(Domain Name)和IP地址(Internet Protocol Address)之间存在一种对应关系,即每个域名都对应着一个或多个IP地址。
这种对应关系由DNS(Domain Name System)服务器来维护。当我们在浏览器中输入一个域名时,浏览器会向本地DNS服务器发送查询请求,请求该域名对应的IP地址。如果本地DNS服务器缓存了这个域名和其对应的IP地址,则直接返回该IP地址;否则,本地DNS服务器会向根DNS服务器发送查询请求,根DNS服务器会返回一个指向该域名所属顶级域名的权威DNS服务器的地址。接下来,本地DNS服务器再向该权威DNS服务器发送查询请求,获取该域名对应的IP地址,并将其缓存起来。最后,本地DNS服务器将该IP地址返回给浏览器,浏览器通过该IP地址访问目标网站。
举例来说,假设我们要访问网站www.example.com。首先,浏览器会向本地DNS服务器发送查询请求,请求该域名对应的IP地址。如果本地DNS服务器没有该域名的缓存记录,它会依次向根DNS服务器、com顶级域名的权威DNS服务器以及example.com域名的权威DNS服务器发送查询请求,最终获取到www.example.com对应的IP地址,并将其缓存起来。之后,本地DNS服务器将该IP地址返回给浏览器,浏览器通过该IP地址访问目标网站。
需要注意的是,一个域名可以对应多个IP地址,这些IP地址通常是为了实现负载均衡、故障转移等功能而提供的。此外,在特定情况下,一个IP地址也可以对应多个域名,例如,多个虚拟主机共用一台服务器的情况。
相关函数
#include
struct hostent *gethostbyname(const char *name);
struct hostent {
char *h_name; /* official name of host */
char **h_aliases; /* alias list */
int h_addrtype; /* host address type */
int h_length; /* length of address */
char **h_addr_list; /* list of addresses */
}
TCP通信
Socket是一种在应用层和传输层之间的接口,可以用于实现不同机器或同一机器上的进程间通信。它是TCP/IP协议族中最基本的网络编程接口之一。
在Socket编程中,一个Socket通常由IP地址和端口号组成,用于标识网络上的一个进程。在客户端程序中,可以创建一个Socket并连接到服务器指定的IP地址和端口号上,从而与该服务器进行通信;在服务器程序中,可以创建一个Socket并绑定到指定的IP地址和端口号上,从而监听客户端连接请求并与其建立连接,以进行通信。
Socket可以使用不同的传输协议,如TCP(Transmission Control Protocol)和UDP(User Datagram Protocol),以实现可靠传输或非可靠传输。对于TCP协议,Socket提供面向连接的服务,数据传输之前需要先建立连接;对于UDP协议,Socket提供无连接的服务,数据传输时不需要事先建立连接。
Socket编程接口通常包含以下几个基本函数:socket()、bind()、listen()、accept()、connect()和send()/recv()等。其中,socket()用于创建Socket;bind()用于将Socket绑定到指定的IP地址和端口号上;listen()用于监听客户端连接请求;accept()用于接受客户端连接请求并建立连接;connect()用于建立与服务器的连接;send()/recv()用于发送和接收数据。
一般根据选择TCP或者UDP有着固定的写法。 socket 函数的返回值是一个非负整数
bind()函数是一种可以在C语言中使用的方法,它可以用来创建一个新的函数,并将指定的this值和参数绑定到新的函数中。当新的函数被调用时,它会自动将这些绑定的值作为参数传递给原始函数。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
listen()是一个网络编程中的函数,用于在服务器上等待客户端连接。当一个客户端请求与服务器建立连接时,服务器使用listen()函数开始监听传入的连接请求。一旦有连接请求到达,它们就被放置在一个队列中,直到服务器准备好接受这些连接并将它们分配给可用的线程或进程。
int listen(int sockfd, int backlog);
其中,sockfd 是套接字描述符,backlog 是等待连接队列的最大长度。
调用 listen 函数后,该套接字变成了被动套接字,可以接受来自客户端的连接请求。backlog 参数指定了等待连接队列的最大长度,如果有新的连接请求到达而等待队列已满,则客户端会收到拒绝连接的错误信息。
一般来说,backlog 的值应该设置为系统能够支持的最大值,以确保能够处理所有可能的连接请求。在 Linux 系统上,可以通过 cat /proc/sys/net/core/somaxconn 命令查看系统支持的最大队列长度,也可以使用 sysctl 命令来修改该值。
accept() 是一个网络编程中的函数,用于接受客户端连接并返回与客户端通信的新 socket 对象和客户端地址。在服务器脚本中,我们使用 listen() 函数开始监听传入的连接请求,当有新的客户端连接请求到达时,accept() 函数会被调用以接受该连接。
accept 函数由服务端调用,用于从全连接队列中取出下一个已经完成的TCP连接。如果全连接队列为 空,那么 accept 会陷入阻塞。一旦全连接队列中到来新的连接,此时 accept 操作就会就绪,这种就绪 是读操作就绪,所以可以使用 select 函数的读集合进行监听。当 accept 执行完了之后,内核会创建一 个新的套接字文件对象,该文件对象关联的文件描述符是 accept 的返回值,文件对象当中最重要的结 构是一个发送缓冲区和接收缓冲区,可以用于服务端通过TCP连接发送和接收TCP段。
区分两个套接字是非常重要的。通过把旧的管理连接队列的套接字称作监听套接字,而新的用于发送和 接收TCP段的套接字称作已连接套接字。通常来说,监听套接字会一直存在,负责建立各个不同的TCP连 接(只要源IP、源端口、目的IP、目的端口四元组任意一个字段有区别,就是一个新的TCP连接),而某 一条单独的TCP连接则是由其对应的已连接套接字进行数据通信的。
#include
#include
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockfd: 必须,表示服务器的 socket 描述符,也就是要监听的套接字。
addr: 可选,表示客户端的地址信息,如果需要获取客户端的地址信息,则需要将这个参数传递给 accept() 函数。
addrlen: 必须,表示客户端地址结构体的长度,在调用 accept() 函数之前必须初始化为客户端地址结构体的大小。
当 accept() 函数成功时,会返回一个新的连接套接字(也就是通信套接字),可以通过这个新的套接字来进行后续的数据传输和通讯。如果 accept() 函数失败,则返回 -1,并设置相应的错误码,可以使用 strerror() 函数来获取对应的错误描述信息。
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
其中,sockfd 是套接字描述符,buf 是一个指向缓冲区的指针,len 是缓冲区的最大长度(以字节为单位),flags 是操作标志位。
该函数返回接收到的字节数,如果出现错误,则返回 -1。可以通过 errno 全局变量获取详细的错误信息。
recv 函数会阻塞当前线程,直到有数据可读或者发生错误。如果在调用 recv 函数之前没有数据可读,那么它将一直阻塞,直到有数据到达为止。
buf 缓冲区用于存储接收到的数据,len 参数指定了缓冲区的大小,即最多能够接收到的数据量。如果实际接收到的数据超过了缓冲区大小,则多余的数据将被丢弃。
flags 参数可以用来指定一些操作标志位,比如是否启用 MSG_OOB(带外数据)模式、是否在接收时进行关闭操作等。
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
其中,sockfd 是套接字描述符,buf 是指向要发送数据的缓冲区的指针,len 是要发送的数据的长度(以字节为单位),flags 是操作标志位。
该函数返回已经成功发送的字节数,如果出现错误,则返回 -1。可以通过 errno 全局变量获取详细的错误信息。
buf 缓冲区用于存储要发送的数据,len 参数指定了需要发送的数据的长度。注意,在发送前应该确保缓冲区中的数据已经准备好,并且长度不超过缓冲区的大小。
flags 参数可以用来指定一些操作标志位,比如是否启动 MSG_OOB(带外数据)模式、是否在发送时进行关闭操作等。
send() 和 recv() 函数是在网络编程中用于发送和接收数据的两个核心函数。这两个函数都是基于套接字(socket)实现的,因此只能用于网络通信。
send() 函数的格式如下:
send(socket, buffer, size, flags)
其中,
send() 函数的返回值为实际发送的数据量,如果出现错误,则返回 -1。
recv() 函数的格式如下:
recv(socket, buffer, size, flags)
其中,
recv() 函数的返回值为实际接收到的数据量,如果出现错误,则返回 -1。
DOS(Denial of Service),即拒绝服务攻击,是一种通过向目标服务器发送大量请求来耗尽其资源以使其无法正常响应的攻击方式。
DOS 攻击可分为两类:
消耗计算资源的攻击。该类攻击通常会向目标服务器发送大量的请求,比如 SYN 请求洪水攻击、Ping 洪水攻击等,使其花费大量 CPU 和内存资源处理这些请求而无法正常工作。
消耗网络带宽的攻击。该类攻击通常会向目标服务器发送大量的数据包,比如 UDP 洪水攻击、ICMP 洪水攻击等,以消耗网络带宽并将目标服务器的网络连接拥堵,使其无法正常响应。
防范 DOS 攻击的措施主要包括以下几点:
增加硬件设备的负载能力,比如增加带宽和存储容量等。
防火墙和入侵检测系统等安全设备的配置和使用。
在服务器端进行限制性访问,比如限制对某些关键资源的访问等。
使用专门的软件和工具来监测网络流量和识别异常活动,及时发现并隔离攻击行为。
增加访问控制的机制,比如限制某些 IP 地址的访问、设置访问频率限制等。
总之,防范 DOS 攻击需要综合考虑硬件设备的能力、网络安全设备的配置和使用、服务器端的安全设置以及对网络流量的监测和管理。
如果是服务端主动调用 close 断开的连接,即服务端是四次挥手的主动关闭方,由之前的TCP状态转换图可知,主动关闭方在最后会处于一个固定2MSL时长的TIME_WAIT等待时间。在此状态期间,如果尝试使用 bind 系统调用对重复的地址进行绑定操作,那么会报错。
如果是服务端主动调用 close 断开的连接,即服务端是四次挥手的主动关闭方,由之前的TCP状态转换 图可知,主动关闭方在最后会处于一个固定2MSL时长的TIME_WAIT等待时间。在此状态期间,如果尝 试使用 bind 系统调用对重复的地址进行绑定操作,那么会报错。
int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);
setsockopt()函数是用于设置套接字选项的系统调用。该函数可以用来设置一系列不同的选项,例如设置套接字的发送或接收缓冲区的大小、开启或关闭Nagle算法、设定超时时间等。
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
sendto()和
recvfrom()`是Linux系统中用于进行UDP套接字通信的函数,它们分别用于发送数据和接收数据。下面是这两个函数的简要说明:
sendto()
函数原型为ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen)
。
参数说明:
sockfd
:需要发送数据的UDP套接字文件描述符。buf
:指向存放待发送数据的缓冲区。len
:待发送数据的长度。flags
:传输控制标志,一般设为0即可。dest_addr
:指向目的地址信息结构体的指针,包括目的IP地址和端口号等信息。addrlen
:指定dest_addr
所指向的地址结构体的长度。返回值:
recvfrom()
函数原型为ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen)
。
参数说明:
sockfd
:需要接收数据的UDP套接字文件描述符。buf
:指向存放接收数据的缓冲区。len
:缓冲区的长度。flags
:传输控制标志,一般设为0即可。src_addr
:指向发送方地址信息结构体的指针,包括发送方IP地址和端口号等信息。addrlen
:指向src_addr
所指向的地址结构体的长度。返回值:
使用UDP协议进行通信时,sendto()
和recvfrom()
函数是常用的数据发送和接收函数,它们可以通过指定目的地址和源地址来实现点对点的通信。同时也可以通过设置传输控制标志等参数来控制数据传输的行为。
这两个函数的行为类似于 send 和 recv
epoll的基本原理:
和 select 一样, epoll 也是一种IO多路复用机制,它可以监听多个设备的就绪状态,让进程或者线程 只在有事件发生之后再执行真正的读写操作。 epoll 可以在内核态空间当中维持两个数据结构:监听事 件集合和就绪事件队列。监听事件集合用来存储所有需要关注的设备(即文件描述符)和对应操作(比 如读、写、挂起和异常等等),当监听的设备有事件产生时,比如网卡上接收到了数据并传输到了缓冲 区当中时,硬件会采用中断等方式通知操作系统,操作系统会将就绪事件拷贝到就绪事件队列中,并且 找到阻塞在 epoll_wait 的线程,让其就绪。监听事件集合通常是一个红黑树,就绪事件队列是一个线 性表。
和 select 相比, epoll 的优势如下:
除了水平触发,还支持边缘触发。
有了这些优势之后, epoll 逐渐取代了 select 的市场地位,尤其是在管理巨
大量连接的高并发场景 中, epoll 的性能要远超 select 。
使用 epoll 主要有这样几个函数:
和 select 一样, epoll 也可以监听已连接队列,判断 accept 是否就绪。配合上超时机制,可以用来实现自动断开功能:超过一段时间未发送消息的客户端的TCP连接会被服务端主动关闭。
epoll_wait 的就绪触发有两种方式:一种是默认的水平触发方式(Level-triggered),另一种是边缘触发 模式(Edge-triggered)。以读事件为例子:水平触发模式下,只要缓冲区当中存在数据,就可以使 epoll_wait 就绪;在边缘触发的情况下,如果缓冲区中存在数据,但是数据一直没有增多,那么 epoll_wait 就不会就绪,只有缓冲区的数据增多的时候,即下图中绿色的上升沿部分时,才能使 epoll_wait 就绪。
使用水平触发的话,线程能够以更短的响应时间来处理事件,但是这可能会导致饥饿问题,如果存在某 个事件传输的数据量过大,那么线程就会多次就绪直到处理完所有数据为止,而一些其他的任务所占用 的资源就会相对变少。使用边缘触发可以避免这个问题,为了确保读操作可以将所有数据读完,可以考 虑使用循环配合非阻塞的形式来处理。
在线程池架构中,主线程通常会将实际的IO交给子线程即工作线程完成,采用边缘触发可以有效地降低 主线程的响应频率,提高整体的性能。除此以外,如果一次请求对应一次响应是用户追求的通信模式, 那么边缘触发正好符合。
我们以一个文件下载的应用为例子来介绍进程池结构:客户端可以向服务端建立连接,随后将服务端中存储的文件通过网络传输发送到客户端,其中一个服务端可以同时处理多个客户端连接的,彼此之间互不干扰。
目前我们传输文件的时候是采用 read 和 send 来组合完成,这种当中的数据流向是怎么样的呢?首先打开一个普通文件,数据会从磁盘通过DMA设备传输到内存,即文件对象当中的内核缓冲区部分,然后调用 read 数据会从内核缓冲区拷贝到一个用户态的buf上面(buf是 read 函数的参数),接下来调用send ,就将数据拷贝到了网络发送缓存区,最终实现了文件传输。
但是实际上这里涉及了大量的不必要的拷贝操作,比如下图中 read 和 send 的过程:
如何减少从内核文件缓冲区到用户态空间的拷贝呢?解决方案就是使用 mmap 系统调用直接建立文件和用户态空间buf的映射。这样的话数据就减少了一次拷贝。在非常多的场景下都会使用 mmap 来减少拷贝次数,典型的就是使用图形的应用去操作显卡设备的显存。除此以外,这种传输方式也可以减少由于系统调用导致的CPU用户态和内核态的切换次数。
使用 mmap 系统调用只能减少数据从磁盘文件的文件对象到用户态空间的拷贝,但是依然无法避免从用户态到内核已连接套接字的拷贝(因为网络设备文件对象不支持 mmap )。 sendfile 系统调用可以解决这个问题,它可以使数据直接在内核中传递而不需要经过用户态空间,调用 sendfile 系统调用可以直接将磁盘文件的文件对象的数据直接传递给已连接套接字文件对象,从而直接发送到网卡设备之上(在内核的底层实现中,实际上是让内核磁盘文件缓冲区和网络缓冲区对应同一片物理内存)。
#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
使用 sendfile 的时候要特别注意, out_fd 一般只能填写网络套接字的描述符,表示写入的文件描述符, in_fd 一般是一个磁盘文件,表示读取的文件描述符。从上述的需求可以得知, sendfile 只能用于发送文件方的零拷贝实现,无法用于接收方,并且发送文件的大小上限是2GB。
考虑到 sendfile 只能将数据从磁盘文件发送到网络设备中,那么接收方如何在避免使用 mmap 的情况下使用零拷贝技术呢?
一种方式就是采用管道配合 splice 的做法。 splice 系统调用可以直接将数据从内核管道文件缓冲区发送到另一个内核文件缓冲区,也可以反之,将一个内核文件缓冲区的数据直接发送到内核管道缓冲区中。所以只需要在内核创建一个匿名管道,这个管道用于本进程中,在磁盘文件和网络文件之间无拷贝地传递数据。
ssize_t splice(int fd_in, loff_t *off_in, int fd_out,loff_t *off_out, size_t len, unsigned int flags);
进程池是一种在并发编程中常用的技术,它可以管理一个固定数量的进程,并且可以重复利用这些进程。通常情况下,在程序运行时,需要处理多个任务,但是如果使用传统的方式,每次都创建新的进程或线程来执行任务,会导致系统资源消耗过大,而且效率较低。因此,我们可以使用进程池来管理一组可复用的进程,从而提高系统的资源利用率和程序的执行效率。
使用进程池的好处是可以充分利用系统资源,提高程序的运行效率。同时,进程池也可以避免频繁地创建和销毁进程,减少系统负担,并且方便进行任务管理和监控。但是,由于进程池中的进程是有限的,所以在提交任务时需要考虑好进程数和任务量之间的平衡,避免出现进程数过多或者任务量过大的情况,从而导致系统资源的浪费。
线程池(Thread Pool)是一种线程管理机制,它可以在创建线程的成本和上下文切换开销之间寻求平衡。线程池会事先创建一定数量的线程并将它们保存在一个队列中,当需要执行任务时,就从队列中取出一个空闲的线程,并将任务交给它执行,当任务完成后,该线程不会被销毁而是继续留在线程池中等待下一次任务的到来。
使用线程池的好处主要有以下几点:
总之,线程池是一种非常实用的技术,可以帮助我们更好地管理线程,提高程序的性能和稳定性。