腾讯二面
1、redis过期策略?
定时删除
当一个键被设置过期时间时,会创建一个定时器(Timer),在过期时间到达时立即删除该键。
实现方式:
使用时间轮或最小堆来管理定时任务。例如,当键的过期时间到达时,触发一个事件直接删除该键。
理论上存在,但Redis默认不采用此策略,因为其资源开销较大。
惰性删除
不主动删除过期键,而是在访问键时检查其是否过期。如果过期则删除,否则继续使用。
实现方式:
每次访问键时,调用expireIfNeeded()函数检查键的过期时间。如果过期,则删除键并返回错误。
作为Redis的默认辅助策略,与定期删除配合使用。
定期删除
定期(默认每秒10次)扫描数据库中的一部分过期键,并删除它们。
实现细节
- 扫描频率: 默认每秒执行10次(由
hz参数控制,默认为10)。 可通过修改redis.conf中的hz参数调整频率(范围1~500,通常不超过100)。 - 扫描过程:
- 将设置了过期时间的键存储在一个字典中。
- 每次扫描时,随机选择一定数量的键(默认20个)检查是否过期,删除过期键。
- 如果删除的键数量超过阈值(如25%),则重复扫描,直到删除速度低于阈值或达到时间限制。
- 时间限制: 每次扫描的最长时间默认为25ms,避免阻塞主线程。
Redis默认的核心策略,与惰性删除结合使用,确保过期键最终被删除。
| 策略类型 | 优点 | 缺点 | 场景 |
|---|---|---|---|
| 定时删除 | 立即释放内存 | 高CPU消耗,资源浪费 | 理论存在,Redis默认不启用 |
| 惰性删除 | 零额外开销,CPU友好 | 可能长期占用内存 | 与定期删除配合使用 |
| 定期删除 | 平衡CPU和内存,避免突发负载 | 过期键延迟删除 | Redis默认核心策略 |
2、redis淘汰策略?
Redis的内存淘汰策略是当Redis的内存使用达到maxmemory(最大内存限制)时,用于决定如何释放内存空间的机制。
触发条件:当Redis的内存使用量超过maxmemory配置的阈值时,才会触发内存淘汰策略。
不淘汰策略(默认策略)
当内存不足以容纳新写入数据时,直接返回错误,不会淘汰任何现有数据。
适用场景:
- 当数据集大小远小于
maxmemory时,确保数据完整性。 - 不适合用作缓存,因为缓存数据集通常会超过内存限制,导致频繁报错。
针对所有键的淘汰策略
这些策略会从所有键(无论是否设置了过期时间)中选择淘汰对象。
| 策略名称 | 行为 | 适用场景 |
|---|---|---|
| allkeys-random | 随机选择一个键并删除。 | 数据访问均匀(无明显冷热区分),或对淘汰策略敏感度低的场景。 |
| allkeys-lru | 选择最近最少使用的键(LRU算法的近似实现)并删除。 | 数据有明显冷热区分,希望保留近期活跃数据的场景。 |
| allkeys-lfu | 选择最近最不频繁使用的键(LFU算法的近似实现)并删除。 | 数据访问频率差异大,希望保留高频访问数据的场景。 |
针对设置了过期时间的键的淘汰策略
这些策略仅从设置了过期时间的键中选择淘汰对象。
| 策略名称 | 行为 | 适用场景 |
|---|---|---|
| volatile-random | 在设置了过期时间的键中随机选择一个并删除。 | 需要淘汰过期键,且对淘汰策略敏感度低的场景。 |
| volatile-ttl | 优先淘汰剩余过期时间最短的键(即“即将过期”的键优先被删除)。 | 希望优先释放即将过期的键的内存,避免后续过期后被删除浪费内存的场景。 |
| volatile-lru | 在设置了过期时间的键中,选择最近最少使用的键(LRU近似)并删除。 | 数据有冷热区分且设置了过期时间,希望保留近期活跃数据的场景。 |
| volatile-lfu | 在设置了过期时间的键中,选择最近最不频繁使用的键(LFU近似)并删除。 | 数据访问频率差异大且设置了过期时间,希望保留高频访问数据的场景。 |
Redis的LRU和LFU算法是近似实现,而非精确实现,目的是减少性能开销:
LRU:
- 每个键记录最近一次访问的时间戳(存储在
RedisObject的lru字段)。 - 淘汰时随机采样
N个键(maxmemory-samples配置,默认10),选择其中lru值最小(最久未使用)的键淘汰。 - 配置:
CONFIG SET maxmemory-samples 100可调整采样数量,数值越大越精确但开销越高。
LFU:
- 统计每个键的访问频率(通过计数器)。
- 淘汰时随机采样
N个键,选择访问频率最低的键淘汰。 - LFU算法在Redis 4.0 引入,支持动态调整(如考虑时间衰减)。
策略选择建议
| 场景描述 | 推荐策略 |
|---|---|
| 数据有明显冷热区分,且希望保留近期活跃数据(如缓存热点数据) | allkeys-lru 或 volatile-lru |
| 数据访问频率差异大,高频数据需优先保留(如高频API调用缓存) | allkeys-lfu 或 volatile-lfu |
| 数据访问均匀,对淘汰策略无特殊要求(如临时缓存) | allkeys-random 或 volatile-random |
| 需优先淘汰即将过期的键,避免后续过期后占用内存 | volatile-ttl |
| 严格禁止数据丢失,但需避免内存溢出(需配合监控和扩容) | noeviction |
3、redis集群中怎么加入或删除一个节点?
添加节点到Redis集群
前提条件
- 集群已存在:至少有一个运行中的Redis集群。
- 新节点已启动:新节点的Redis服务已启动,并且配置文件已正确设置为集群模式。
- 网络连通性:新节点与现有集群节点之间可以互相通信(端口开放)。
步骤1:准备新节点
修改配置文件(
redis.conf):bashcluster-enabled yes # 启用集群模式 port <new-node-port> # 指定新节点的端口(如6380) cluster-config-file nodes-<port>.conf # 集群配置文件名 bind 0.0.0.0 # 允许远程访问(根据需求配置)启动新节点:
bashredis-server /path/to/redis.conf
步骤2:将新节点加入集群
方法1:使用 redis-cli --cluster add-node
redis-cli --cluster add-node <existing-node-ip>:<existing-port> <new-node-ip>:<new-node-port>参数说明:
<existing-node-ip>:<existing-port>:已存在的集群节点地址(如192.168.1.100:6379)。<new-node-ip>:<new-node-port>:新节点地址(如192.168.1.101:6380)。
比如:
redis-cli --cluster add-node 192.168.1.100:6379 192.168.1.101:6380方法2:使用 CLUSTER MEET 命令
redis-cli -p <existing-node-port> CLUSTER MEET <new-node-ip> <new-node-port>示例:
redis-cli -p 6379 CLUSTER MEET 192.168.1.101 6380步骤3:分配槽位(Slots)
新节点加入后,默认不会分配任何槽位,需手动或自动分配槽位使其参与数据存储:
手动分配槽位:
bash# 为新节点分配指定范围的槽位(例如分配0-5461槽) redis-cli -p <existing-node-port> CLUSTER ADDSLOTS <slot-number>自动迁移槽位(使用
redis-cli --cluster reshard):bashredis-cli --cluster reshard <existing-node-ip>:<existing-port>按提示操作:
- 输入总槽位数(默认16384)。
- 输入要迁移的槽位数(如100)。
- 指定源节点(通常为现有节点)和目标节点(新节点)。
步骤4:验证节点状态
# 连接到任意节点,检查集群状态
redis-cli -p <node-port> cluster nodes
redis-cli -p <node-port> cluster info删除节点从Redis集群
前提条件
- 节点已脱离集群:确保节点不再处理请求,并且槽位已迁移至其他节点。
- 备份数据(可选):删除节点前建议备份数据。
步骤1:迁移槽位
如果要删除的节点是主节点,必须先将它的槽位迁移到其他节点:
# 使用reshard命令迁移槽位
redis-cli --cluster reshard <existing-node-ip>:<existing-port>按提示选择要迁移的槽位和目标节点。
步骤2:删除节点
方法1:使用 redis-trib.rb 工具
redis-trib.rb del-node <existing-node-ip>:<existing-port> <node-id>获取节点ID:
redis-cli -p <node-port> cluster nodes输出中每个节点的第一列是节点ID(如17ad4f3eacb3990c0a95f81d0fed77d5d0365482)。
方法2:使用 CLUSTER FORGET 命令
# 连接到任意节点,执行命令:
CLUSTER FORGET <node-id>步骤3:验证删除结果
redis-cli -p <node-port> cluster nodes确认目标节点已从列表中移除。
4、redis集群和主从的优缺点?
对比表
| 特性 | 主从模式 | 集群模式 |
|---|---|---|
| 数据分片 | 无,数据集中存储在主节点 | 支持,数据分散到多个节点 |
| 水平扩展 | 仅扩展读能力,无法扩展写能力 | 支持,可扩展读写能力 |
| 高可用性 | 需哨兵模式,故障转移依赖人工/哨兵 | 内置副本机制,自动故障转移 |
| 写性能瓶颈 | 主节点为单点,写性能受限 | 写操作分散到多个主节点,性能更高 |
| 数据一致性 | 主从存在延迟,可能不一致 | 支持强一致性(多副本确认) |
| 配置复杂度 | 简单,适合中小规模 | 复杂,需至少6节点保证高可用 |
5、b+树的优缺点,查询稳定吗?
B+树的优点
高效利用磁盘I/O
- 树的高度较低: B+树的每个节点可以存储大量键值(通常一个节点对应一个磁盘块),因此树的高度远低于二叉树(如红黑树)。例如,16KB的磁盘块可存储数百个键,使得树的高度通常在3~4层,查询时只需少量I/O操作(如3次磁盘读取即可定位数据)。
- 磁盘块对齐: 每个节点的大小设计为与磁盘块大小一致,读取一个节点时可一次性加载整个块,减少寻道时间。
稳定的查询性能
- 固定查询路径: 所有数据仅存储在叶子节点,因此任何查询都必须遍历从根节点到叶子节点的路径,路径长度固定(树的高度固定)。例如,查询单点数据或范围数据的I/O次数相同,避免了B树因非叶子节点存储数据导致的路径长度波动。
- 范围查询高效: 叶子节点通过指针形成有序链表,范围查询(如
WHERE age BETWEEN 20 AND 30)可直接顺序遍历,无需回溯父节点,效率远高于B树。
数据存储优化
- 非叶子节点仅存索引: 非叶子节点仅存储键和子节点指针,不存储实际数据。这使得同一磁盘块可存储更多键,进一步降低树的高度。
- 全序存储: 叶子节点的数据按顺序排列,适合数据库的全表扫描或排序操作,且缓存命中率更高。
插入与删除稳定性
- 局部调整: 插入或删除操作通常仅影响树的局部区域(如叶子节点的分裂或合并),不会导致树的全局结构剧烈变化,保证了数据的稳定性。
- 冗余设计: 叶子节点的链表结构使删除操作无需重构整个树,仅需修改局部指针。
B+树的缺点
插入与删除复杂度较高
- 分裂与合并操作: 当节点满时需分裂(如插入导致节点键数超过上限),空节点过多时需合并,这些操作涉及父节点的键调整,可能引发级联操作(如父节点分裂或合并)。
- 空间管理开销: 需要维护节点的空闲空间或链表指针,增加管理复杂度。
空间利用率权衡
- 键重复存储: 非叶子节点的键需要向上级节点重复存储(如父节点需保存子节点的最大键),可能增加存储开销。
- 叶子节点存储全部数据: 数据全部存储在叶子节点,可能导致树的宽度增加,间接影响高度。
复杂查询的局限性
- 多维索引效率低: B+树仅适合单字段索引的范围查询,对多维数据(如空间索引)需结合其他结构(如R树)。
- 非范围查询的冗余: 若查询条件不涉及范围(如精确匹配),B+树的链表结构未被充分利用,可能略逊于B树(B树可能在非叶子节点提前命中)。
6、哨兵模式中,主节点宕机怎么保证数据是最新的?
在Redis哨兵模式中,当主节点宕机时,通过主从复制机制和哨兵的故障转移逻辑来保证数据的最新性。
核心机制
主从复制:
- 主节点(Master)将数据变更(写操作)同步到从节点(Slaves)。
- 从节点通过复制偏移量记录与主节点的同步进度,确保数据一致性。
哨兵监控:
- 哨兵(Sentinel)节点定期检查主从节点的健康状态(如心跳检测)。
- 当主节点宕机时,哨兵会触发故障转移流程,选择一个数据最新的从节点升级为新主节点。
数据最新性的保证步骤
故障检测与确认
- 主观下线: 当某个哨兵节点检测到主节点不可达(如连续多次心跳失败),会标记该主节点为“主观下线”。
- 客观下线: 主观下线后,该哨兵会与其他哨兵节点通信,广播主节点的故障状态。如果大多数哨兵也认为主节点不可达,则标记为主节点“客观下线”,触发故障转移。
选择新主节点
选举最新从节点:
哨兵会从所有从节点中选择复制偏移量最大且与主节点最后一次同步时间最近的从节点作为新主节点。
复制偏移量:记录从节点与主节点同步的最后位置(如
master_last_io_seconds_ago越小,表示同步越及时)。优先级规则:
1. 复制偏移量最大的从节点(数据最新) 2. 与主节点最后一次同步时间最早的从节点(避免延迟过长)
避免数据丢失: 选择数据最新的从节点作为新主节点,确保其数据尽可能接近原主节点宕机前的最新状态。
完成故障转移
- 升级从节点为新主节点: 哨兵向选中的从节点发送
SWITCH MASTER命令,使其成为新主节点。 - 重新配置集群:
- 其他从节点会重新指向新主节点进行同步。
- 客户端需通过哨兵获取新主节点的地址(如通过
SENTINEL GET-MASTER-ADDR-BY-NAME命令)。
持久化机制作为补充
AOF/RDB备份:
如果所有从节点的数据都严重滞后,哨兵无法保证数据完全最新。此时需结合持久化机制(如AOF日志或RDB快照)恢复数据:
- AOF日志:新主节点可加载最新的AOF文件恢复数据。
- RDB快照:结合增量复制(如
redis-check-dump工具)修复数据。
7、主从节点的复制过程?
复制过程分为全量复制和增量复制两个阶段。
全量复制
全量复制是主从节点初次同步或网络中断后重连时的同步方式。
从节点发起同步请求
从节点发送PSYNC命令(Redis 2.8+)或SYNC命令(旧版本)到主节点,请求同步数据。
PSYNC命令:格式为PSYNC <master_runid> <offset>,其中:
<master_runid>:主节点的唯一标识(UUID)。<offset>:从节点已同步的复制偏移量(若为初次同步,offset设为-1)。
主节点生成RDB快照
主节点接收到同步请求后,执行以下操作:
- 触发BGSAVE命令:在后台生成RDB快照文件(通过fork子进程完成,避免阻塞主线程)。
- 记录后续写命令:将BGSAVE执行期间的所有写操作记录到缓冲区(repl缓冲区)中。
主节点发送RDB文件
BGSAVE完成后,主节点将RDB文件通过网络传输给从节点。
- 传输方式:通过已建立的TCP连接直接传输,无需先将RDB写入磁盘(Redis 2.8+优化)。
- 并行处理:若多个从节点同时请求同步,主节点只需生成一次RDB文件,减少资源消耗。
从节点加载RDB文件
从节点接收到RDB文件后:
- 清空当前所有数据。
- 加载RDB文件到内存,完成数据初始化。
- 加载完成后,从节点向主节点发送确认信号。
主节点发送缓冲区的写命令
- 主节点将BGSAVE执行期间记录的写命令(repl缓冲区)发送给从节点。
- 从节点执行这些命令,确保数据与主节点一致。
同步完成
从节点完成RDB加载和命令执行后,进入正常增量复制阶段。
增量复制
在全量复制完成后,主从节点进入增量复制阶段。
主节点的写命令传播
复制积压缓冲区:
- 主节点维护一个固定大小的环形缓冲区(默认1MB),用于存储最近执行的写命令。
- 缓冲区的大小可通过配置参数
repl-backlog-size调整。
复制偏移量:
- 每个写命令执行后,主节点和从节点的复制偏移量递增。
- 从节点记录自己已同步到的偏移量,主节点记录所有从节点的偏移量。
从节点断线重连
若从节点与主节点短暂断开,重新连接时:
- 从节点发送
PSYNC命令,携带上次的offset和主节点的runid。 - 主节点检查:
- 如果
offset对应的数据仍在repl backlog中,则返回CONTINUE,并发送缺失的命令。 - 否则,触发全量复制(数据已超出缓冲区范围)。
- 如果
同步增量数据
- 主节点将从
offset开始的写命令从repl backlog中提取,发送给从节点。 - 从节点执行这些命令,恢复数据一致性。
8、虚拟内存的作用?
作用
缓解物理内存不足
解决内存瓶颈:
当物理内存不足以满足程序需求时,虚拟内存通过分页机制将部分数据临时转移到硬盘(分页文件,如Windows的PageFile.sys),释放物理内存供活跃程序使用。
- 分页文件:硬盘上的固定区域,存储暂时不用的内存数据(如未使用的程序或后台任务的数据)。
- 动态分配:操作系统根据程序需求动态决定哪些数据保留在物理内存,哪些交换到硬盘。
提供连续的逻辑地址空间
连续性抽象:
程序运行时,操作系统为每个进程分配一个虚拟地址空间(如32位系统为4GB,64位系统可达16EB),程序以为自己独占连续内存,但实际上:
- 物理内存碎片化:物理内存可能被多个进程分割成不连续的块。
- 磁盘扩展:部分数据存储在硬盘,通过页表映射到虚拟地址。
避免内存碎片: 虚拟内存通过分页管理(固定大小的内存块,如4KB),避免了传统内存分配的碎片问题。
支持多任务与大型程序
多任务基础:
虚拟内存允许同时运行多个程序,每个进程拥有独立的虚拟地址空间,互不干扰。
地址隔离:进程A的虚拟地址0x1000可能映射到物理内存的不同位置,与进程B的0x1000无冲突。
支持超大程序: 即使物理内存有限,虚拟内存也能让程序使用远超物理内存的地址空间(如64位系统支持EB级虚拟地址)。
保护系统稳定性
隔离程序崩溃影响: 通过虚拟地址空间隔离,一个程序的错误(如内存越界)仅影响自身,不会直接导致整个系统崩溃。
权限控制: 操作系统通过页表设置内存页的访问权限(如只读、执行禁止),防止恶意程序篡改关键数据。
优化内存使用效率
分页置换:
当物理内存不足时,操作系统通过页面置换算法(如LRU)选择最不常用的页面换出到硬盘,腾出空间给活跃数据。
算法优化:现代系统采用更智能的算法(如第二机会钟面算法),减少频繁换页(Thrashing)。
预读与缓存: 系统可预加载程序可能需要的页面到物理内存,提升访问速度。
工作原理
1. 分页机制
虚拟地址到物理地址的转换:
- 页表:操作系统维护的数据结构,记录虚拟页到物理页的映射关系。
- MMU(内存管理单元):CPU硬件模块,负责将虚拟地址转换为物理地址。若发现某页未在物理内存(缺页),触发缺页中断。
分页流程:
- 程序访问虚拟地址
VA。 - MMU通过页表查询对应的物理页帧。
- 若页在内存中:直接访问。
- 若页不在内存(缺页):
- 触发中断,操作系统从硬盘加载该页到空闲物理页帧。
- 更新页表,继续执行程序。
2. 分页文件
存储位置:硬盘上的固定文件(如Windows的C:\pagefile.sys)。
作用:
- 存储被置换出物理内存的页面。
- 作为交换空间,防止内存耗尽导致程序崩溃。
性能权衡: 硬盘读写速度远低于内存(机械硬盘约100MB/s,SSD约500MB/s,而内存可达几十GB/s),频繁交换会导致系统变慢。
3. 页错误处理
- 类型:
- 可恢复错误:页面需从硬盘加载到内存(如程序首次访问未加载的页面)。
- 不可恢复错误:无效地址访问(如程序错误),系统终止进程。
- 处理流程:
- CPU检测到缺页中断。
- 操作系统定位需加载的页面。
- 分配物理页帧,加载数据。
- 更新页表,恢复程序执行。
9、进程间通信的方式?
老生常谈的问题了,这里给大家整一个表。
IPC方式对比
| 方式 | 方向 | 速度 | 数据量 | 同步机制 | 适用场景 |
|---|---|---|---|---|---|
| 信号 | 单向 | 快 | 小(仅信号号) | 无(异步) | 事件通知(如终止进程) |
| 匿名管道 | 单向 | 快 | 中等 | 无(需进程配合) | 父子进程通信 |
| 命名管道 | 双向 | 快 | 大 | 无(需进程配合) | 无亲缘关系进程通信 |
| 共享内存 | 双向 | 极快 | 大 | 需信号量/锁 | 高频大数据交换(如游戏引擎) |
| 消息队列 | 双向 | 中 | 结构化数据 | 内核管理 | 结构化数据传递(如任务调度) |
| 套接字 | 双向 | 中 | 大 | 协议层保证 | 跨网络通信(如Web服务) |
| 信号量 | 无 | N/A | 控制信号 | 同步工具 | 资源访问控制(如生产者-消费者) |
| 内存映射文件 | 双向 | 快 | 大 | 需文件锁 | 文件与内存共享(如数据库缓存) |
| 文件 | 双向 | 慢 | 大 | 需文件锁 | 简单数据交换或日志 |
10、线程和进程的区别?
看表吧
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 资源 | 独立拥有资源 | 共享所属进程的资源 |
| 调度单位 | 进程是资源分配单位,线程是执行单位 | 线程是CPU调度的基本单位 |
| 隔离性 | 高(进程间相互隔离) | 低(线程间共享资源) |
| 通信方式 | 需IPC(如管道、消息队列) | 直接共享内存(需同步机制) |
| 错误影响范围 | 仅影响自身 | 可能导致整个进程崩溃 |
| 性能 | 开销大,切换慢 | 开销小,切换快 |
11、锁和信号量的区别?
| 维度 | 锁(Lock) | 信号量(Semaphore) |
|---|---|---|
| 核心功能 | 互斥:保证同一时间只有一个线程访问共享资源。 | 同步:协调多个线程/进程的执行顺序或资源访问。 |
| 资源管理 | 仅管理一个资源的访问权(如互斥锁只能是0或1)。 | 管理多个同类资源(如计数信号量的值可为任意非负整数,表示可用资源数量)。 |
| 操作规则 | 加锁和解锁必须由同一个线程完成。 | 信号量的释放(V操作)和获取(P操作)可由不同线程完成。 |
| 数值范围 | 二进制值(0或1):0表示资源被占用,1表示可用。 | 整数范围:可为任意非负整数,表示可用资源的数量。 |
| 适用场景 | 保护共享资源(如临界区、全局变量)。 | 协调线程/进程执行流程(如生产者-消费者)、管理多个资源(如并发访问数据库连接池)。 |
| 实现机制 | 通过原子操作(如lock()和unlock())实现互斥。 | 通过P(Wait)和V(Signal)操作控制资源计数,阻塞或唤醒线程/进程。 |
| 错误风险 | 死锁风险:未正确解锁或加锁顺序不当。 | 资源计数错误:信号量初始值或操作顺序不当可能导致资源泄漏或死锁。 |
| 线程操作灵活性 | 必须由同一线程解锁,否则可能导致死锁。 | 不同线程可分别执行P和V操作,例如生产者增加资源(V),消费者减少资源(P)。 |
| 是否支持多资源管理 | 仅支持单资源互斥。 | 支持多资源管理(如同时允许多个线程访问资源)。 |
12、实现一个无锁访问共享资源?
无锁编程的核心是通过原子操作和乐观并发控制实现线程间共享资源的安全访问,避免使用传统锁(如互斥锁、信号量)导致的线程阻塞和性能开销。
点到为止,大家可以自行实现以下。
13、介绍一下i/o多路复用?
I/O多路复用是一种同步I/O模型,允许单个进程或线程同时监视多个文件描述符(如网络套接字、文件等),当这些描述符中的任意一个准备好进行I/O操作时(可读、可写或异常),进程或线程能够立即响应并处理。其核心目标是高效处理多个I/O请求,避免传统阻塞I/O模型中因线程或进程过多导致的资源浪费。
核心思想
- 监视多个文件描述符:将多个文件描述符(如网络连接、文件句柄)注册到I/O多路复用机制中。
- 阻塞等待事件:调用I/O多路复用函数(如
select、epoll_wait)时,进程会被阻塞,直到有描述符准备好I/O操作。 - 事件通知与处理:当某个描述符就绪(可读、可写等),函数返回并通知应用程序,应用程序遍历所有描述符,处理已就绪的事件。
14、怎么从文件描述符获取对应文件?
Linux系统中,从文件描述符(File Descriptor, FD)获取对应文件的路径或名称,可以通过内核提供的/proc文件系统实现。
Linux的/proc文件系统为每个进程维护了一个虚拟目录/proc/[PID]/fd/,其中:
[PID]是进程的进程号。fd目录下包含该进程所有打开的文件描述符(FD)的符号链接,每个链接指向实际的文件路径。
例如:
- 进程PID为
1234的文件描述符3对应的文件路径可以通过/proc/1234/fd/3获取。 - 如果文件描述符
3指向文件/home/user/data.txt,则/proc/1234/fd/3会是一个符号链接,指向该路径。
15、select/poll的区别?底层原理了解过吗?
| 特性 | select | poll |
|---|---|---|
| 文件描述符限制 | 默认最大支持 1024 个(受 FD_SETSIZE 限制)。 | 无硬性限制,受限于内存(理论上支持数十万)。 |
| 数据结构 | 使用 fd_set 定长数组(位掩码)。 | 使用 struct pollfd 动态数组(可变长度)。 |
| 事件处理 | 水平触发(Level-Triggered):重复报告就绪事件。 | 水平触发(Level-Triggered):同上。 |
| 时间精度 | struct timeval(微秒级)。 | int(毫秒级)。 |
| 信号处理 | 无信号屏蔽功能。 | 无信号屏蔽功能(需通过 pselect 实现)。 |
| 效率 | 复制 fd_set 开销大,遍历所有描述符。 | 复制 pollfd 数组开销较大,但结构更灵活。 |
| 内核处理方式 | 内核遍历所有描述符,拷贝 fd_set。 | 内核遍历所有描述符,拷贝 pollfd 数组。 |
16、介绍一下epoll?
epoll 的核心是两个数据结构:
红黑树:
- 存储所有注册的 FD,用于快速查找和管理(插入、删除操作时间复杂度为 O(log N))。
- 每个 FD 对应一个
epitem结构体,记录事件类型和回调函数。
双向链表:
- 存储就绪的 FD,供
epoll_wait快速获取。 - 当 FD 的状态变化时(如可读、可写),内核通过回调函数将 FD 添加到该链表。
epoll 的核心函数
epoll_create(int size)
创建 epoll 实例,返回 epoll 文件描述符(epfd)。
参数:size 是提示值(自 Linux 2.6.8 后被忽略,内核动态管理)。
返回值:成功返回 epfd,失败返回 -1。
epoll_ctl(int epfd, int op, int fd, struct epoll_event \*event)
对 epoll 实例进行事件注册、修改或删除。
参数:
epfd:epoll 实例的文件描述符。op:操作类型(EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL)。fd:要操作的文件描述符。event:事件类型(如EPOLLIN)和用户数据。
结构体 epoll_event:
struct epoll_event {
__uint32_t events; // 事件类型(如 EPOLLIN、EPOLLOUT)
epoll_data_t data; // 用户数据(如 FD、指针等)
};epoll_wait(int epfd, struct epoll_event \*events, int maxevents, int timeout)
等待并获取就绪的事件。
参数:
epfd:epoll 实例的文件描述符。events:存储返回事件的数组。maxevents:数组长度(最大支持事件数)。timeout:超时时间(毫秒,-1 表示无限等待)。
返回值:就绪事件的数量,0 表示超时,-1 表示错误。
epoll 的两种触发模式
水平触发(Level-Triggered, LT)
当 FD 处于就绪状态时,持续触发事件,直到应用程序处理完所有数据。
- 即使数据未完全处理,下次
epoll_wait仍会返回该 FD。 - 适合简单场景,但可能因重复事件导致多次唤醒。
示例:EPOLLET 未设置时默认为 LT。
边缘触发(Edge-Triggered, ET)
仅在 FD 的状态变化时触发一次事件(如从不可读变为可读)。
- 需一次性读取/写入所有数据,否则可能错过后续事件。
- 需配合非阻塞 I/O,避免阻塞等待。
示例:设置 EPOLLET 标志启用 ET 模式。
17、select/poll/epoll之间优缺点?
| 特性 | select | poll | epoll |
|---|---|---|---|
| 文件描述符限制 | 默认最大 1024(受 FD_SETSIZE 限制) | 理论上支持数十万(受限于内存) | 无硬性限制(仅受系统文件数限制,如 file-max) |
| 事件触发模式 | 仅支持 水平触发(LT) | 仅支持 水平触发(LT) | 支持 LT 和 边缘触发(ET) |
| 数据结构 | fd_set(定长位掩码) | struct pollfd(动态数组) | 红黑树 + 就绪链表(高效内核数据结构) |
| 效率 | 低(线性扫描所有 FD) | 中(线性扫描所有 FD) | 高(仅处理就绪 FD,无需遍历全部) |
| 拷贝开销 | 用户空间与内核空间频繁拷贝 fd_set | 拷贝 pollfd 数组(动态但仍有开销) | 内核直接通知,减少拷贝 |
| 适用场景 | 小规模连接(<1024) | 中等规模连接(<10万) | 高并发场景(数十万连接,如 Web 服务器、Redis) |
18、子进程会拷贝父进程的数据吗?为什么?
子进程在创建时(通过 fork() 系统调用),不会立即完全拷贝父进程的所有数据,而是通过 写时拷贝(Copy-on-Write, COW) 机制实现资源的高效共享。
内存区域的拷贝规则
代码段(Text Segment)
完全共享:子进程与父进程共享同一份代码段的物理内存。
只读属性:代码段是只读的,因此无需复制。
原因:子进程和父进程执行相同的程序代码,无需修改代码段。
数据段(Data Segment)
初始共享:子进程与父进程共享数据段的物理页面。
写时拷贝:当子进程或父进程尝试修改数据段的某个页面时,系统会为该页面分配新的物理内存,复制原有内容,之后修改操作仅作用于新页面。
结果:修改前数据是共享的,修改后该页面变为独立。
堆(Heap)和栈(Stack)
与数据段类似:初始共享物理页面,修改时触发写时拷贝。
堆和栈的独立性:子进程和父进程的堆和栈在逻辑上是独立的,但物理页面在未修改前是共享的。
其他资源(如文件描述符、环境变量)
共享或复制:子进程会复制父进程的文件描述符表、环境变量等资源,但文件描述符本身是共享的(如条目[10]所述,文件偏移量也会共享)。
写时拷贝(COW)机制的实现原理
fork()的执行过程:- 父进程调用
fork()时,内核会为子进程创建一个新的进程控制块(PCB),分配唯一的 PID。 - 子进程的虚拟地址空间会被初始化为与父进程完全相同,但物理页面不立即复制。
- 父进程和子进程的页表项指向相同的物理页面,并标记为 只读。
- 父进程调用
写操作触发拷贝:
- 当父进程或子进程尝试写入某个共享页面时,会触发 缺页异常(Page Fault)。
- 内核检测到写操作违反了“只读”权限,此时才会为该页面分配新的物理内存。
- 新的物理页面内容是原页面的拷贝,页表项被更新为指向新页面,并标记为可写。
- 此后,该页面的修改仅影响当前进程,另一进程仍使用原页面。
效率优化:
如果父子进程在
fork()后未对共享页面进行写操作(如立即执行exec()替换代码),则无需实际拷贝任何数据,极大节省内存和时间。
为什么需要写时拷贝?
节省内存:
- 若直接复制所有内存,当父进程创建大量子进程时,内存消耗会急剧增加。
- COW 仅在必要时复制数据,避免不必要的拷贝。
提升
fork()效率:- 复制大量内存会显著增加
fork()的执行时间,而 COW 使fork()几乎是常数时间复杂度。
- 复制大量内存会显著增加
支持常见场景:
子进程通常在
fork()后立即调用exec()替换自身程序(如启动新程序),此时无需拷贝数据。
19、算法题:最大连续子序列和?
问题描述
给定一个整数数组,找出一个连续子数组(至少包含一个元素),使得该子数组的和最大,并返回最大和。
示例: 输入:nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4] 输出:6 解释:子数组 [4, -1, 2, 1] 的和最大,为 6。
解题思路
动态规划思想: 通过维护两个变量,逐步计算以当前元素结尾的最大子数组和,并更新全局最大值。
关键变量:
current_sum:以当前元素结尾的最大子数组和。max_sum:全局最大子数组和。
递推公式: current_sum[i] = max(nums[i], current_sum[i-1] + nums[i]) 即:
- 如果
current_sum[i-1]是负数,则丢弃之前的子数组,从当前元素nums[i]重新开始。 - 否则,将当前元素加入之前的子数组。
参考代码(C++)
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int maxSubArray(vector<int>& nums) {
if (nums.empty()) return 0; // 处理空数组
int max_sum = nums[0]; // 全局最大值
int current_sum = nums[0]; // 当前子数组和
for (size_t i = 1; i < nums.size(); ++i) {
// 当前子数组和:要么包含前一个元素,要么从当前元素重新开始
current_sum = max(nums[i], current_sum + nums[i]);
// 更新全局最大值
max_sum = max(max_sum, current_sum);
}
return max_sum;
}
int main() {
vector<int> nums = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
cout << "最大子数组和为:" << maxSubArray(nums) << endl; // 输出 6
return 0;
}