Socket套接字(客户端,服务端)
目录
- socket是什么
- 一、在客户端
- 1. 创建套接字
- 2. 设置服务器地址
- 3. 连接到服务器
- 4. 发送数据
- 5. 接收数据
- 6. 关闭连接
- 二、内核态与用户态切换
- 三、系统调用与上下文切换的关系
- 四、在服务端
- 1. 创建 Socket (用户态)
- 2. 绑定
- 3. 监听
- 4. 接受连接
- 5. 接收消息 (用户态 -> 内核态)
- 五、IO多路复用
- 主要区别
- 1. select()
- 2. poll()
- 3. epoll()
- epoll工作模式
- select()开销
- 位图是什么
socket是什么
Socket(套接字) 是一种用于在网络中进行进程间通信(一般是不同主机之间进程)的编程接口。它提供了一种标准的方法,使得不同计算机上的应用程序能够相互通信。
一、在客户端
通过套接字(SOCKET)在客户端和服务器之间传输数据是网络编程中的一种常见方式。下面是详细步骤,包括相关的内核态和用户态切换的说明。
1. 创建套接字
- 用户态:
在客户端使用系统调用socket()
创建一个套接字。 - 内核态:
系统调用进入内核态,内核为套接字分配资源并返回文件描述符。
2. 设置服务器地址
- 用户态:
定义服务器的地址结构并设置相关信息,如 IP 地址和端口号。
代码示例:
3. 连接到服务器
- 用户态:
使用connect()
系统调用发起连接。 - 内核态:
连接请求被发送到服务器,内核处理 TCP 握手(SYN、SYN-ACK、ACK)以建立连接。
4. 发送数据
-
用户态:
使用send()
或write()
系统调用发送数据 -
内核态:
数据被复制到内核缓冲区,内核负责将数据打包并通过网络协议栈发送到服务器。
5. 接收数据
- 用户态:
使用recv()
或read()
系统调用接收来自服务器的数据。 - 内核态:
内核检查网络接口接收缓冲区,若有数据则将数据复制到用户空间的缓冲区,并返回接收到的字节数。
6. 关闭连接
- 用户态:
使用close()
系统调用关闭套接字。 - 内核态:
内核释放与套接字相关的资源,处理 TCP 的四次挥手(FIN、FIN-ACK、ACK)以关闭连接。
二、内核态与用户态切换
- 当用户态程序调用系统调用(如
socket()
、connect()
、send()
、recv()
等)时,CPU 从用户态切换到内核态,执行内核中的相关函数。 - 完成内核任务后,内核会将控制权返回给用户态程序,继续执行。
- 系统调用作为软中断:系统调用本质上是一种软件中断(也称为“软中断”)
三、系统调用与上下文切换的关系
系统调用确实涉及到 CPU 上下文切换
-
用户态与内核态:
- 在现代操作系统中,CPU 有两种运行模式:用户态和内核态。用户态是应用程序运行的状态,而内核态是操作系统内核执行的状态。
- 系统调用通常发生在用户态到内核态的切换。
-
发起系统调用:
- 当应用程序需要访问硬件资源或执行特权操作(如文件读写、网络通信等)时,它会通过特定的系统调用接口发起请求。这一请求会触发一个软中断,或者直接调用一个特殊的指令(如
syscall
或int
指令)。
- 当应用程序需要访问硬件资源或执行特权操作(如文件读写、网络通信等)时,它会通过特定的系统调用接口发起请求。这一请求会触发一个软中断,或者直接调用一个特殊的指令(如
-
上下文切换步骤:
- 保存当前上下文:在发起系统调用时,操作系统需要保存当前用户程序的执行状态(包括寄存器内容、程序计数器等),以便在返回时能够继续执行。
- 切换到内核态:操作系统会将 CPU 模式从用户态切换到内核态,并开始执行相应的系统调用处理程序。
- 执行系统调用:内核根据系统调用的类型执行具体操作。
- 恢复上下文:系统调用完成后,操作系统将之前保存的用户程序上下文恢复,并将 CPU 模式从内核态切换回用户态。
- 继续执行:最后,控制权返回给用户程序,继续其执行。
四、在服务端
1. 创建 Socket (用户态)
2. 绑定
3. 监听
4. 接受连接
服务端通过 accept 方法阻塞等待客户端的连接。
当有客户端尝试连接时:
内核态会处理 TCP 三次握手。
连接建立,accept() 方法将返回一个新的 socket 对象(用于与客户端通信)以及客户端的地址信息。
socket返回一个fd(文件描述符),对fd的操作就是对io文件流的操作。
准备 fd_set(select方法特有),在使用 select() 之前,你需要创建并初始化一个 fd_set 结构体,来表示你希望监控的文件描述符(FD)。这个结构体可以包含多个 socket 描述符
除了select()使用的 fd_set(位图),还可能是数组(poll),红黑树/链表(epoll)
调用 select() 函数
一旦你设置好 fd_set,就可以调用 select() 函数。这个函数会阻塞,直到至少有一个 socket 变为就绪状态。
处理就绪的 Socket
当 select() 返回后,需要检查哪个 socket 已经就绪。这可以通过再次遍历你的 fd_set 来完成
5. 接收消息 (用户态 -> 内核态)
服务端使用 recv 方法接收数据。
用户态发起 recv 调用,请求从 socket 中读取数据。
控制权切换到内核态,内核检查是否有可读的数据。
如果有数据,内核将数据复制到进程的地址空间(用户态),然后返回给用户态。
五、IO多路复用
与多进程和多线程技术相比,IO 多路复用技术的最大优势是系统开销小,系统不必创建进程或线程,也不必维护这些进程,从而大大减小了系统的开销。
select()、poll() 和 epoll() 都是用于实现 I/O 多路复用的系统调用
主要区别
特性 | select() | poll() | epoll() |
---|---|---|---|
文件描述符限制 | 限制为 FD_SETSIZE (通常为1024) | 没有限制 | 没有限制 |
数据结构 | 位图 | 数组 | 红黑树/链表 |
性能 | 性能随监视的文件描述符数量增加而降低 | 能够处理较多的文件描述符 | 性能稳定,尤其在监视大量文件描述符时 |
灵活性 | 不灵活,需每次调用前设置 | 相对灵活,能够处理 |
1. select()
- 用户进程需要监控某些资源 fds,在调用 select 函数后会阻塞,操作系统会将用户线程加入这些资源的等待队列中。
- select() 的内部实现通常使用一个时间轮询机制,通过不断检查每个文件描述符的状态来判断其是否就绪。
- 直到有fd就绪(有数据可读、可写或有 except异常)或超时(timeout 指定等待时间,如果立即返回设为 null
即可),函数返回。 - select 函数返回后,中断程序唤起用户线程。用户可以遍历 fds,通过 FD_ISSET 判断具体哪个 fd
收到数据,并做出相应处理。
优点
- 简单,易于理解和使用。
- 标准化,几乎所有 UNIX-like 操作系统都支持。
缺点
- 文件描述符数量受到限制,32 位系统最多能监听 1024 个 fd,64 位最多监听 2048 个。
- 每次调用 select 都需要将进程加入到所有监视 fd 的等待队列,每次唤醒都需要从每个队列中移除。 这里涉及了两次遍历,而且每次都要将整个 fd_set 列表传递给内核,有一定的开销。
- 当函数返回时,系统会将就绪描述符写入 fd_set 中,并将其拷贝到用户空间。进程被唤醒后,用户线程并不知道哪些 fd 收到数据,还需要遍历一次。
- 对于大量的文件描述符,性能会显著降低。
2. poll()
- 数组结构:poll() 使用一个结构体数组(struct pollfd)来存储要监视的文件描述符及其事件类型。这使得它能够监视更多的文件描述符,而不受 FD_SETSIZE 的限制。
- 事件标志:每个文件描述符都可以设置多个事件类型,如可读、可写等,并且可以通过事件结果获取哪些事件发生了。
优点
- 没有文件描述符数量的硬性限制(受限于系统资源)。
- 适合处理大量的文件描述符。
缺点
- 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符。
- 事件检测偏向线性搜索,对于高负载场景性能较差。
3. epoll()
步骤:
- 创建 epoll 实例:调用 epoll_create() 或 epoll_create1() 来创建一个 epoll 实例,这将返回一个用于后续操作的文件描述符。
- 注册文件描述符
- 等待事件:当注册的文件描述符上发生事件时,可以调用 epoll_wait() 阻塞等待事件的发生。该函数会填充就绪的事件信息。
- 处理事件:根据 epoll_wait() 返回的就绪事件,进行相应的处理。每个事件包含了发生事件的文件描述符和事件类型,应用程序可以据此决定如何响应。
- 修改或删除事件:在事件处理过程中,如果需要修改现有的事件或从 epoll 中删除某个文件描述符,可以再次调用 epoll_ctl()。
- 清理资源
epoll 主要基于事件通知机制,它允许用户注册需要监控的文件描述符,并在这些文件描述符上发生特定事件时得到通知。与 select() 和 poll() 的线性扫描不同,epoll 使用了一种基于事件的模型,可以显著提高性能。
epoll 使用一个文件描述符管理多个描述符,将用户进程监控的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间只需拷贝一次。
所有 FD 集合采用红黑树存储,就绪 FD 集合使用链表存储。这是因为就绪 FD 都需要处理,业务优先级需求,最好的选择便是线性数据结构。
优点
- 高效,尤其适合大量文件描述符的情况,性能不会随着监视的文件描述符数量增加而明显下降。
- 支持边缘触发和水平触发模式,可以更灵活地管理事件。
缺点
- 仅在 Linux 环境下可用
epoll工作模式
1)LT模式
LT(level triggered)模式:也是默认模式,即当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件,并且下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。
2)ET模式
ET(edge-triggered)模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
ET 是一种高速工作方式,很大程度上减少了 epoll 事件被重复触发的次数。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
为何高效
1) epoll 精巧的使用了 3 个方法来实现 select 方法要做的事,分清了频繁调用和不频繁调用的操作。
epoll_ctrl 是不太频繁调用的,而 epoll_wait 是非常频繁调用的。而 epoll_wait 却几乎没有入参,这比 select 的效率高出一大截,而且,它也不会随着并发连接的增加使得入参越发多起来,导致内核执行效率下降。
2) mmap 的引入,将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。
3)红黑树将存储 epoll 所监听的 FD。高效的数据结构,本身插入和删除性能比较好,时间复杂度O(logN)。
select()开销
- 调用过程中的开销
当你调用 select()
时,通常需要进行以下几个步骤:
-
设置文件描述符集合:在用户空间中,你需要创建并初始化一个
fd_set
结构,包含所有你希望监视的文件描述符。这一步需要你手动管理这个集合。 -
进入系统调用:在进行
select()
系统调用时,整个fd_set
集合会被传递给内核。因为fd_set
是一个位图,表示一组文件描述符的信息,内核需要扫描这个集合来检查每个文件描述符的状态(可读、可写等)。这意味着内核会在进入系统调用时遍历你的fd_set
,检查每一个文件描述符的状态。 -
注册等待:内核将进程加入到所有这些文件描述符的等待队列中,每个文件描述符都有自己的等待队列。(当多个进程或线程对同一文件描述符执行 I/O 操作时,可能会出现需要等待的情况,所以每个文件描述符都有自己的等待队列。)当某个文件描述符变为就绪状态时,内核会将进程从这个队列中移除,从而唤醒进程。这就涉及到了两次遍历:一次是检查所有文件描述符的状态,另一次则是在返回时将进程从对应的队列中移除。
- 返回结果时的开销
当 select()
函数返回时,内核会更新你的 fd_set
结构,标记哪些文件描述符已经就绪。此时,内核需要将这些状态信息拷贝回用户空间。这个过程也有几步:
-
填充
fd_set
:内核会根据它所监视的文件描述符状态,更新你的fd_set
结构。这种更新是基于内核对文件描述符的实际状态的判断。 -
拷贝到用户空间:更新完成后,内核需要将这个更新的
fd_set
结构拷贝到你的程序的用户空间,因为用户程序需要知道哪些文件描述符现在可以进行 I/O 操作。
- 用户层处理
一旦 select()
返回,用户线程接下来要处理的是已就绪的文件描述符。即便内核已经在 fd_set
中更新了状态,用户还是需要遍历这个 fd_set
,找出哪些文件描述符已经准备好进行操作。这意味着:
- 用户程序需要再次遍历
fd_set
,这增加了额外的开销。 - 这也使得处理逻辑更加复杂,因为用户需要解析哪些文件描述符是就绪的,并做相应的处理。
位图是什么
位图(Bitmap)是一种用于表示集合的高效数据结构,它使用一组位(binary digits,0 和 1)来表示某个元素是否存在于集合中。这种表示方式非常节省空间并且可以快速进行查找、插入和删除等操作。
位图的基本概念
- 每个位表示一个元素:在位图中,每一个位置对应一个特定的元素。例如,如果你有一个最多包含
n
个元素的集合,那么可以用n
个位来表示。- 如果某一位为
1
,则表示该元素在集合中。 - 如果某一位为
0
,则表示该元素不在集合中。
- 如果某一位为
示例
假设我们有一个整数集合 {0, 2, 3, 5}
,我们可以使用位图来表示这个集合。假设我们的集合元素范围是从 0 到 7(即 8 个可能的元素),那么我们就可以用 8 位来表示:
元素索引: 0 1 2 3 4 5 6 7
位图: 1 0 1 1 0 1 0 0
在这个例子中:
- 位图的第 0 位为 1,表示 0 存在于集合中。
- 位图的第 1 位为 0,表示 1 不在集合中。
- 位图的第 2 位为 1,表示 2 存在于集合中。
- 以此类推。
位图的优点
- 空间效率:相较于其他数据结构(如链表),位图在存储稀疏集合时占用更少的空间。
- 快速访问:可以通过简单的位运算快速检查元素是否存在、添加或删除元素,例如使用位与(AND)、位或(OR)等操作。
- 简化算法:位图可以使一些算法变得更加简单和高效,特别是在处理大规模数据和稀疏数组时。
位图的缺点
- 固定大小:位图需要预先定义范围,如果元素超出这个范围,将会导致无法再添加新的元素。
- 浪费空间:如果集合中的元素分布稀疏,位图可能会造成大量未使用的位,从而浪费空间。
应用场景
- 集合操作:如求并集、交集、差集等。
- 内存管理:用于表示内存块的使用情况,例如进程的页表。
- 网络协议:用于标识已收到的数据包。
- 图像处理:在某些情况下,可以用位图表示像素状态。
总结
位图是一种非常有效率的数据结构,尤其适用于那些元素范围固定且需要频繁查询的场合。它的优势在于极大的提升了元素存在性检测的速度,并且在某些应用中提供了显著的空间节约。