ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

转行程序员必看:手写实现反996算法,3秒看懂面试考点

转行程序员必看:手写实现反996算法,3秒看懂面试考点 转行程序员必看:手写实现反996算法,3秒看懂面试考点 看了一堆教程还是不会写项目?别急,今天直接上干货。很多转行的小伙伴在面试时,总觉得自己背了很多八股文,但面试官一问“你怎么在代码层面优化性能”或者“如何设计高并发下的公平性”,脑子就一片空白。其实,很多看似宏大的系统设计问题,核心都落回到了基础的算法实现上。比如今天我们要聊的“反996”机制,在编程语境下,它常被映射为任务调度的公平性算法,或者说是防止某个任务无限占用资源、饿死其他任务的策略。 这可不是什么虚头巴脑的理论,而是后端开发中处理队列、消息分发时的底层逻辑。如果你还在死记硬背,那确实很难落地。真正的能力,体现在你能不能手写实现一个基本的调度器。 考点梳理:为什么面试官爱问“公平性” 在 Java 后端或者 Go 语言的高并发场景里,公平性(Fairness)是一个高频考点。所谓的“反996”,在这里我们将其具象化为:如何确保长时间运行的任务不会独占 CPU 或线程池,导致短任务或新任务一直等待? 很多初级开发者容易混淆几个概念:FIFO(先进先出):最基础,但不公平,长任务会阻塞短任务。 SJF(短作业优先):对短任务友好,但长任务可能饿死。 RR(时间片轮转):通过时间片切换,实现宏观上的公平,这就是我们“反996”的核心——给每个人(任务)固定的时间,谁也不许霸占。面试中,面试官问“反996”或者“防止任务饿死”,其实就是在考察你对时间片轮转调度的理解。你需要明白,操作系统和线程池是如何通过 timeslice 来强制切换上下文的。 在掘金技术社区的不少高赞后端文章中,都提到过:高性能系统的设计,往往不是追求极致的速度,而是追求极致的稳定性与公平性。一个公平的调度算法,能保证 P99 延迟不爆炸,这比平均延迟更重要。 标准答法:怎么回答才显专业 面对这个问题,不要只说“用时间片”。你要分层次回答: 第一层:原理简述 “反996”机制在代码层面通常体现为带时间片限制的轮询调度。每个任务被分配一个固定的时间片(Time Slice),一旦时间片耗尽,无论任务是否执行完,都必须让出 CPU,排到队列末尾。这样保证了所有活跃任务都有机会执行,避免了某个长任务“霸占”资源。 第二层:与 FIFO 的区别 FIFO 是排队,前面的不办完,后面的只能干等着。如果第一个是“大单”(长任务),后面的“小单”(短任务)就得饿着。而时间片轮转,是大单办一会儿,停下来,让小单办一会儿,再回来办大单。这样大家都能感受到“服务”的存在。 第三层:落地场景 在 Web 服务器中,如果不用这种机制,一个慢查询(比如复杂的 SQL 或远程 RPC 调用)可能会阻塞整个线程池,导致其他正常请求超时。通过限制每个任务的处理时间,或者在异步框架中通过协程切换,实现逻辑上的“反996”。 注意:这里要强调,真正的“反996”在代码里不仅仅是时间片,还包括优先级队列与**老化机制(Aging)**的结合。如果某个任务一直排队,它的优先级要逐渐提高,防止饿死。 代码实现:手写一个公平调度器 光说不练假把式。我们用 Python 来手写实现一个简单的、基于时间片的任务调度器。这段代码模拟了操作系统内核中调度器的核心逻辑,非常适合作为面试时的白板题练习。 import time from collections import dequeclass Task:def __init__(self, task_id, duration, name=Task):self.task_id = task_idself.duration = duration # 总需要执行的时间self.remaining = duration # 剩余执行时间self.name = nameself.is_finished = Falseclass FairScheduler:模拟反996机制的公平调度器核心思想:时间片轮转 + 防止饿死def __init__(self, time_slice=10):self.time_slice = time_slice # 每个任务允许连续执行的时间片self.ready_queue = deque() # 就绪队列self.completed_tasks = [] # 已完成的任务记录self.current_time = 0 # 系统时钟def add_task(self, task: Task):添加任务到就绪队列self.ready_queue.append(task)print(f[{self.current_time}] 新任务加入: {task.name} (需时: {task.duration}))def run(self):调度主循环while self.ready_queue:if not self.ready_queue:break# 取出队首任务task = self.ready_queue.popleft()# 计算本次允许执行的时间# 取时间片与剩余时间的最小值exec_time = min(self.time_slice, task.remaining)print(f[{self.current_time}] 执行 {task.name}, 分配时间片: {exec_time})# 模拟执行# 在实际系统中,这里是上下文切换self.current_time += exec_timetask.remaining -= exec_timeif task.remaining 0:# 任务未完成,放回队列末尾(轮转)self.ready_queue.append(task)# 这里可以加入老化逻辑:每轮队列,任务优先级+1else:# 任务完成task.is_finished = Trueself.completed_tasks.append(task)print(f[{self.current_time}] 任务 {task.name} 完成!)# 测试场景 if __name__ == __main__:scheduler = FairScheduler(time_slice=5)# 模拟三个任务:一个长任务,两个短任务# 如果没有时间片限制,长任务会阻塞短任务scheduler.add_task(Task(1, 20, 长任务-数据库查询))scheduler.add_task(Task(2, 5, 短任务-用户登录))scheduler.add_task(Task(3, 10, 中任务-日志记录))print(- * 30)scheduler.run()代码逐行讲解:FairScheduler 类:这是核心。time_slice 是控制“反996”的关键参数。值越小,切换越频繁,公平性越好,但上下文切换开销越大;值越大,切换少,但短任务等待时间可能变长。 ready_queue:使用双端队列(Deque)是因为我们需要从头部取出任务,执行完未完成的再追加到尾部。这完美模拟了循环链表的结构。 exec_time = min(self.time_slice, task.remaining):这是防止超时的关键。如果任务只剩 1ms 就要完了,没必要给它 10ms 的时间片,直接跑完即可。 self.ready_queue.append(task):当任务时间片用完但未完成时,它必须排到后面。这就是“反996”的本质——不许霸占资源,办完这一茬,赶紧让位,下次再轮到你。进阶避坑: 在实际生产环境(如 Java 的 ThreadPoolExecutor 或 Go 的 GOMAXPROCS 调度),单纯的时间片轮转是不够的。上下文切换开销:频繁的 popleft 和 append 在真实 CPU 上意味着保存和恢复寄存器,开销巨大。所以操作系统通常会结合优先级,只有高优先级任务才会抢占低优先级任务的时间片。 I/O 阻塞:如果任务是在等待 I/O(比如网络请求),它不应该占用 CPU 时间片。真正的公平调度器会将 I/O 等待的任务挂起,放入阻塞队列,而不是就绪队列。上面的 Python 代码是纯 CPU 密集型模拟,实际后端开发中,要区分 CPU 密集和 I/O 密集。追问与延伸:面试官还会问什么 当你给出了上面的代码和解释,面试官通常会追问以下两点,这也是区分“背题家”和“实战派”的关键: 追问1:如果时间片设置得太小,会发生什么?答:上下文切换(Context Switch)的频率会增加。每次切换都需要保存当前任务的现场(CPU 寄存器、栈指针等),并恢复下一个任务的现场。这会导致 CPU 大量时间浪费在切换上,而不是执行有效代码。极端情况下,CPU 利用率可能下降,甚至导致“抖动”(Thrashing)。追问2:如何处理“饥饿”问题?比如一个新加入的任务总是排在一个长任务后面,永远轮不到它?答:引入老化机制(Aging)。在调度器中,记录每个任务在队列中等待的轮数。每等待一轮,任务的优先级提升一级。这样,即使新任务初始优先级低,等待久了也会变成高优先级,从而被优先调度。这在数据库连接池、消息队列(如 Kafka 的某些配置)中都很常见。追问3:Java 中 Thread.sleep 和 wait 在调度上有什么区别?答:sleep 只是让线程暂停执行,不释放锁,时间到了自动唤醒。wait 是释放锁,等待其他线程 notify。在公平调度视角下,sleep 的线程虽然不占用 CPU,但它仍然在“队列”中占位(取决于具体实现),而 wait 的线程会被移出就绪队列,进入阻塞队列,彻底不参与 CPU 竞争,直到被唤醒。记忆口诀:三秒记住核心 为了方便记忆,我给你总结了一个口诀,面试紧张时心里默念: “片小切频高,片大等待长; 长任务霸占,短任务遭殃; 轮转加老化,公平不慌张; I/O 挂起走,CPU 别白忙。” 解读:前两句讲时间片大小的权衡。 中间两句讲为什么要轮转(防止长任务霸占)。 后两句讲优化手段(老化防饿死,I/O 分离)。结尾互动 技术面试,考的从来不是你会背多少,而是你能不能把抽象的“公平”、“效率”概念,转化为具体的代码逻辑。 “反996”这个梗,在代码世界里就是资源分配的公平性。下次当面试官问你“如何优化线程池的调度”或者“如何防止某个请求拖垮整个服务”时,别忘了把“时间片”和“老化机制”这两个词抛出来,再配合一段手写实现的思路,你的回答瞬间就会从“小白”变成“老兵”。 这个知识点你面试被问过吗?或者你在实际项目中遇到过“大请求阻塞小请求”的情况吗?留言说说你的解决方案,咱们评论区见真章。
返回列表