golang Context源码阅读

前言 本文基于go1.21,不同版本的Context内部实现可能会有细微差别 使用场景 为什么需要Context,首先思考一个场景:客户端去请求某个服务,这个服务依赖于多个可并行执行的下游服务,为了提高这个服务的响应速度,自然会为每个下游服务开启一个协程去执行。 倘若在执行的过程中,客户端取消了对这个服务的请求,下游服务也应该被停掉。但是go不提供直接关闭协程的方法,我们可以使用chan来协作式地关闭这些子协程:在服务代码中创建一个no buffer的chan并传递给这些子协程,通过子协程read chan+父亲协程close chan来达到通知子协程关闭的效果。 我们将这个场景扩展一下: 在这个场景中,child svc1又依赖于两个下游服务,并且某次请求中child svc1因为某些原因会取消对下游服务的请求(不影响其他服务节点的正常运行),老样子,在child svc1中创建一个chan并通过这个chan去关闭下游协程就好了。child svc1的代码可能会这么写: func childSvc1(upstream <-chan) { downstream := make(chan struct{}, 0) defer close(downstream) go childSvc1_1(downstream) go childSvr1_2(downstream) for { select { case <-upstream: close(downstream) return default: // 处理业务... // 因为某些原因需要取消下游服务 close(downstream) } } } 可以看到这段小snippet还能勉强阅读,但是从使用语义上来说,chan本身是用于传递数据的,这种read+close来关闭协程的方式就是某种hack手段,对于有一定规模的项目来说,这样的使用方式可读性将会非常差并且容易出错。因此我们需要一个带有“取消下游”语义的库来完成这种任务。 Context则很好地充当了这样的角色,它不但能控制下游的生命的生命周期,还自带超时控制、取消传递、并发安全、数据传递等功能: 取消传递:父Context的取消会自动传递到所有子孙Context,使得子孙Context也自动取消 超时控制:通过 context.WithTimeout 和 context.WithDeadline,提供自动超时取消机制 数据传递:可以携带请求范围内的数据(比如请求ID、path、begin timestamp等),这个特性在分布式链路追踪框架中被广泛使用 上面第二个场景中的代码换成Context来实现: import "context" func childSvc1(ctx context.Context) { cancel, ctx := context.WithCancel(ctx) // 创建子Context defer cancel() go childSvc1_1(ctx) go childSvr1_2(ctx) for { select { case ctx.Done(): return default: // 处理业务... // 因为某些原因需要取消下游服务 cancel() } } } 带超时控制的Context: ...

九月 14, 2024 · by NOSAE

golang底层知识汇总

Note 本文基于go1.21.2,不同版本的go可能会有差异。文中部分代码会由于不是知识点强相关而省略,但被忽略的每行代码或多或少都有它们实际的用处,甚至可能不宜删除,欢迎指出 Tip 分析底层的相关的代码时,往往会由于平台架构而带来代码上的差异,比如我机器是darwin/arm64,使用vscode查看代码,而且我希望基于linux/amd64来看代码,可以在.vscode/settings.json中设置GOOS和GOARCH: ...

九月 3, 2024 · by NOSAE

MQ

消息队列引入的好处 通过异步处理提高系统性能(减少响应所需时间) 削峰/限流 降低系统耦合性。 消息队列引入的问题 系统可用性降低:需要处理mq宕机问题 系统复杂度提高:需要处理消息重复、丢失、保序等问题 一致性问题:消息没有被正确消费的话,引入一致性问题 kafka特点 高吞吐量、低延迟。topic可以分为多个partition,消费者组的消费者并行对topic进行消费 可扩展性:支持热扩展 持久性:消息被持久化到磁盘,并支持数据备份 容错性:partition replica 高并发:支持数千个客户端同时进行读写 kafka架构 Producer(生产者) : 产生消息的一方。 Consumer(消费者) : 消费消息的一方。 Consumer Group(消费者组):同一个组内不同消费者负责消费不同的partation,消费者组之间互不影响 Broker(代理) : 可以看作是一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。 Controller:通过 zk 从 Brokers 中选举出来管理整个 Broker 集群的 Broker,名为 Controller。Controller 通过定时任务,或者监听器模式获取 zk 信息,将 zk 的变动通过事件的方式发送给事件队列,队列就是一个LinkedBlockingQueue,事件消费者线程组通过消费消费事件,将相应的事件同步到各 Broker 节点。 每个Broker又包含了topic和partition Topic(主题) : Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic 来消费消息。 Partition(分区) : 一个 Topic 划分为 Partition ,分布在不同的 Broker 上,便于负载均衡以及并发消费。每个 Partition 以文件夹的形式存储在文件系统中。每个对应的 Partition 数据目录下存储 .index,.log ,.timeindex三种文件 partition实际上就可以看成是一个队列。 ...

九月 2, 2024 · by NOSAE

MySQL

select流程 连接 获取TCP连接,查询用户的权限,该权限保存在连接中,就算管理员改了用户权限,该连接的权限不会变。 空闲连接的最大空闲时长由wait_time控制,超过最大时长就自动断开。 最大连接数由max_connections控制,超过最大连接数就拒绝新的连接。 MySQL连接也分长连接和短连接: 连接 mysql 服务(TCP 三次握手) 执行sql 断开 mysql 服务(TCP 四次挥手) // 长连接 连接 mysql 服务(TCP 三次握手) 执行sql 执行sql 执行sql .... 断开 mysql 服务(TCP 四次挥手) 由于很多资源保存在内存中,并且在连接断开后才释放,长连接很多很可能MySQL程序占用内存过大导致被系统杀死并重启,对于长连接的解决方式是客户端主动调用mysql_reset_connection重置连接释放内存,这个过程不需要重连和重新做权限验证。 在实际开发中(以Java为例),会使用DBCP、C3P0或者Druid的连接池来管理连接,由连接池处理这种问题。 解析SQL 词法分析+语法分析。 这一步不会检查表/字段是否存在,只是单纯检查SQL语法 执行SQL 预处理:检查表/字段是否存在、将select *展开为所有字段 优化:制定执行计划,选择索引(使用explain SQL语句可以查看执行计划) 执行:执行器根据执行计划,将索引传给引擎去查询一条符合的记录,得到一条记录后检查剩余的非索引条件是否满足,满足的话发送这条记录给客户端。不断重复上面这个过程。 redo log(引擎层) redo log具有crash safe的能力 redo log是物理日志,记录的是“在某个数据页上做了什么修改” redo log是循环写的,空间固定会用完。 redo log buffer是全局共用的,参数innodb_flush_log_at_trx_commit=N控制write和fsync,N=0的时候不write也不fsync,N=1的时候每个事务提交都write+fsync,N=2的时候只write bin log(服务层) binlog是归档日志,没有crash safe的能力 binlog是逻辑日志,比如“给 ID=2 这一行的 c 字段加 1" binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志 binlog+某个时间点的数据库全量备份,可以将数据库恢复到之前某个时间点,比如误删了数据库想要恢复: 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库; 然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。 每个线程维护自己的binlog cache,参数sync_binlog=N控制fsync,0为不fsync,N为每N个事务fsync。为N的话,binlog最多会丢失N个binlog日志。 ...

九月 2, 2024 · by NOSAE

OS

软中断 中断请求的处理程序应该要短且快,因为中断处理程序要求关中断(不接受新的中断请求),如果中断处理程序执行时间过长,可能在还未执行完中断处理程序前,会丢失当前其他设备的中断请求。 为了解决由于中断处理时间过长,导致新来的中断丢失,将中断分成两个部分: 硬中断:先关中断,处理跟硬件紧密相关或者时间敏感的事情 软中断:由内核触发,完成该中断剩余的耗时工作 其中硬中断直接抢占cpu,而软中断有专门的内核线程ksoftirqd处理,由操作系统调度执行。 软中断不只是设备中断的下半部分,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁。 linux上/proc/softirqs记录当前各类型软中断个数,如果NET_RX个数变化过快,说明很多网络包打进来,可以用tcpdump抓包分析,如果发现是异常流量,可以加防火墙,如果是正常流量,需要考虑升级硬件。 进程的状态 挂起态:处于阻塞状态的进程,进程可能会占用着物理内存空间,那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。(sleep或者ctrl+z) 进程和线程比较 创建: 进程是资源(内存、文件等)分配的基本单位,线程是调度的基本单位 进程创建涉及内存管理、文件管理等操作。而线程独享的资源只有寄存器和栈,对于内存和文件只需要共享即可 线程销毁更快,因为相对要释放的资源更少 切换调度 同一进程的线程切换更快,因为线程共享进程的地址空间,内存管理单元不涉及切换过程,不需要切换页表,不用冲刷TLB,要切换的上下文信息也少了很多 数据传递 同一进程的线程传递数据更快,由于共享内存、文件等资源,那么线程传递数据不需要经过内核,经过内核意味着需要切换到系统栈上执行,相当于切换上下文,并且系统调用由于不相信外界用户代码,会有额外的检查工作 死锁 条件: 互斥 资源不可剥夺 循环等待 占有并等待 切换内核态开销大的原因 每个进程都会有两个栈,一个内核态栈和一个用户态栈。当中断执行时就会由用户态栈转向内核态栈,系统调用时需要进行栈的切换,而且内核代码对用户不信任,需要进行额外的检查。系统调用的返回过程有很多额外工作,比如检查是否需要调度等。 虚拟内存 进程访问一个虚拟地址时,CPU芯片中的内存管理单元MMU会将其映射为物理地址,最终拿着这个物理地址去访问内存。而虚拟地址物理地址的映射方式主要有分段和分页两种。 分段:由程序员将虚拟内存划分为一个个段,虚拟地址划分为段号和段内偏移量,段号用于在段表中查询段的物理基地址、段大小、特权等级等。段基地址+段内偏移量就构成了物理地址。 分段存在的问题: 内存碎片:会有外部碎片。解决外部碎片是通过内存交换,也就是通过swap的方式实现了碎片整理,具体是将在用的内存先交换到硬盘上,然后再装载到内存当中,但是不是装载回原来的位置,而是紧邻上一片正在使用的内存。 内存交换率低:由于涉及到读写硬盘,因此swap的效率低是必然的。 综上,分段由于经常出现外部内存碎片,经常触发swap整理碎片,导致整个机器的卡顿。 分页:由操作系统将虚拟内存划分成一个个大小相同的页,虚拟地址划分为页号和页内偏移量,页号用于在页表中查询页的物理页号,拼接上页内偏移量就构成了物理地址。 分页的特点:消除了外部碎片,并且通过限制页的大小,限制了每次swap也只有一个或几个页,提高swap的效率。但是由于操作系统给进程分配物理内存是以页为单位的,当一个页没有全部使用到的话,会出现内部碎片,但页大小比较小,因此内部碎片的大小也会控制在一定范围内。 多级页表:由于一个虚拟地址必须通过查询页表来找到对应的物理页,而32位机器+4KB页大小的环境下,一个进程的页表占据4MB大小,这样纯属是一种浪费。因此引入二级页表,让一级页表长居内存,而二级页表在需要的时候再加载到内存,由于程序的空间局部性,最好的情况下内存只需要为进程保留一级页表和一页的二级页表。 linux内存布局 每个进程的虚拟内存空间划分为用户空间和内核空间,所有进程的内核空间实际上映射的是同一块物理内存: 下面是用户空间的具体布局: 其中文件映射段的内存和堆内存一样都是动态分配的,比如使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。 malloc分配内存的原理 malloc可能会使用brk或者mmap这两个系统调用来向操作系统申请内存:当分配内存小于128KB则用brk,大于128KB则用mmap。brk的内存在free之后会归还内存池,不会归还给操作系统,而mmap的内存free之后直接归还给操作系统。因此,如果全部使用mmap来分配内存,那么每次分配内存都触发系统调用,并且每次都触发缺页中断,效率非常低。 OOM原理 触发异步回收,不阻塞进程执行 触发同步回收,阻塞进程执行 OOM 可以被回收的内存分为文件页和匿名页,对于脏页,会触发IO写回硬盘,否则可以直接释放。对于匿名页(堆、栈等用户程序使用到的内存)则通过swap机制换出到硬盘。因为触发磁盘IO,降低系统运行效率,看起来就是发生卡顿。 如果没有空闲文件页和匿名页,并且也没有未分配的物理内存,就会触发OOM,根据算法选择占用物理内存较高的进程,然后将其杀死,以便释放内存资源,直到有足够的内存进行分配。 如何保护一个进程不被OOM杀掉:OOM killer选择被杀程序的时候通过计算一个分数来决定,分数越高越优先被杀 // points 代表打分的结果 // process_pages 代表进程已经使用的物理内存页面数 // oom_score_adj 代表 OOM 校准值 // totalpages 代表系统总的可用页面数 points = process_pages + oom_score_adj*totalpages/1000 oom_score_adj可设置的范围为[-1000, 1000],默认为0,只与进程占用的物理内存页数相关。可以通过设置oom_score_adj为 -1000,降低该进程被 OOM 杀死的概率。一般是特别重要的系统服务才会做这样的配置。 ...

九月 2, 2024 · by NOSAE