爱在山水间,坚持拍照片

今年夏天实在太热了,为保护相机免于中暑基本上没怎么拍照片 😆,这回立秋了整理一下相机里的照片,可以开拍了。 雍和宫 人是真多,镜头只能往上拍   地坛公园 黄色琉璃瓦确实好看   亮马河 亮马河治理的不错,划船的、钓鱼的、游泳的可以说是其乐融融共饮一江水了, 这张照片让我想起了曾经外卖行业三足鼎立的盛况   故宫旁边   三只祥禽瑞兽 继遇到鹿鹿、得得多年之后,立秋那天又遇到了提提,看来铲屎擦地的频率需要进一步提高了。

2024/8/26
articleCard.readMore

PHP 这么拉?长连接都搞不了?说说 PHP 的 socket 编程

对 PHP 的误解颇深 网络上似乎存在一种现象,一提到 PHP 人们的第一反应是简单且慢,这种简单甚至已经到了简陋的地步,比如不少人认为 PHP 无法独立创建一个服务,只能配合 Apache 或 Nginx 一起使用,而且 PHP 只能在处理完请求后销毁资源关闭进程,所以也无法处理长连接业务,这些都是对 PHP 的误解,我想这种误解的形成可能与 PHP 的发展历史有关,实际上 PHP 能做的有很多,下面就先从 PHP 的发展历史说起。   PHP 的发展简史 在我看来,PHP 的发展路线确实与其他主流编程语言不太相同。PHP 天生就是为了 Web 而生的,早期的 Web 网页都是静态的,例如在个人主页上展示一些固定的个人信息,为了能够让网页展示一些动态的统计数据和简单的交互,Rasmus Lerdorf 在 1995 年开发了 Personal Home Page 工具集合,简称为 PHP,PHP 通过 CGI 协议与 Web 服务器交互,通过实时计算生成动态的内容。这里可能是与其他主流编程语言差别最大的点,其他语言的运行环境要么是通过编译后直接执行,要么是在命令行中调用解释器执行的。 因为 PHP 最初的目标就是做一些简单的计算,所以并不具备主流编程语言中的一些高级特性,后来越来越多的网站开始使用 PHP 并希望能提供更多的功能,之后 Lerdorf 将 PHP 开源,在这之后 Zeev Suraski 和 Andi Gutmans 重写了 PHP 的解析器,并从此开始 PHP 改为 Hypertext Preprocessor,新版的解析器命名为 Zend Engine,Zend 的命名来自于两位作者的名字。至此 PHP 支持了面向对象、命名空间等特性,已经脱胎换骨成为了一门完善的编程语言。在 2015 年 PHP 7 发布,重构了 PHP 中很多重要且常用的数据结构,内存占用得到显著优化,性能也得到了大幅提升。   火爆的 LAMP 架构 虽然 PHP 经过几次版本迭代已经具备了现代编程语言的必要特性,但依旧有很多人对 PHP 有着类似前面提到的种种误解,造成这种误解的原因很大程度上是因为曾经 Web 领域中应用最广泛的架构 - LAMP 实在是太火了。在这套架构中 Linux 作为操作系统,MySQL 用于数据存储,Apache 负责处理网络连接和 HTTP 协议,而 PHP 放在其后面负责处理动态内容。由于这套架构简单有效且开源免费,可以低成本快速搭建起一个可用的服务,这对于初创团队业务试错来说十分具有吸引力,一度出现了很多一键安装的集成软件包,让这套架构的上手门槛进一步降低,但长此以往可能让不少人以为 PHP 只能配合 Apache 或 Nginx 使用,而 PHP 远不止于此。放在 Apache 或 Nginx 后面只是 PHP 运行模式的一种,也就是 CGI 模式,此外 PHP 支持其他模式,下面做一个对比。   PHP 运行的几种模式 按我的理解,PHP 运行模式严格来说就分两种,CGI 模式和 CLI 模式,CGI 后来衍生出了 Apache mod、FastCGI、FPM 等模式。   CGI 模式 CGI (Common Gateway Interface)通用网关接口是一种协议,是早期 Web 服务器与外部程序交互的一种方式,Web 服务器与外部程序之间通过环境变量、标准输入和标准输出交换数据。 CGI 的 logo 是一个三棱镜,其中一束光穿过三棱镜被分解成不同颜色,象征着 CGI 可以将网络请求分解并传递给不同应用程序处理,展现出了 CGI 的多样性和灵活性。 遵循 CGI 协议的 Web 服务器一般会有一个名为 cgi-bin 的目录,目录下面默认都是可执行 CGI 脚本文件,如果前端访问到了这些文件那么 Web 服务器并不会像处理普通文件那样直接将文件返回给前端,而是会 fork 出子进程并在子进程中运行指定的 CGI 脚本,脚本运行完成后通过标准输出将结果返回给 Web 服务器,并关闭子进程。 运行前 Web 服务器会将一些必要的请求信息设置在环境变量中,CGI 脚本运行后便可以通过读取环境变量得到这些请求信息,例如 uri、请求参数等。CGI 脚本的标准输出会重定向给 Web 服务器,服务器接到输出后返回给前端,这就是为什么早期的 CGI 模式下运行的 PHP 程序可以通过 echo 来返回结果的原因。 这种模式特点是比较简单,并且由于每次处理完成后都会销毁进程和资源,所以也不会出现内存泄漏等问题,但缺点是由于每次都需要重新创建新的进程并销毁,性能开销较大,也无法利用到长连接或池化技术,在处理大量并发请求时处理能力较低。   FastCGI 模式与 PHP-FPM 为了解决 CGI 模式下每次都要新建子进程并销毁子进程导致的性能低下问题,FastCGI 模式在 CGI 基础上做出了改进,这种模式下会预先创建出一些 CGI 进程常驻内存,当有请求到来时会分配一个空闲进程处理,完成后并不销毁而是作为空闲进程重新等待处理请求。 FastCGI 是协议,而 PHP-FPM 是 FastCGI 的实现,全称为 PHP FastCGI Process Manager。这种模式根本上还是基于 CGI 模式衍生出来的,主要优化的是引入常驻内存特性以及多个 FPM 进程的管理,减少了频繁开启关闭进程带来的性能损耗,但由于 Web 服务器与 FPM 进程之间还是短连接,所以这种模式不支持与客户端的长连接。   CLI 模式 CLI 模式则是直接使用 PHP 解释器来运行 PHP 代码,例如 php test.php,在我看来无论哪种编程语言,CLI 模式才应该是最为广大人民群众所喜闻乐见的模式,但由于 PHP 以 CGI 以及 FastCGI 模式运行实在太过深入人心,以至于 CLI 模式反而对很多人来说较为陌生。 在这种模式下 PHP 的运行方式与其他高级编程语言区别并不大,支持常见的系统调用,就算不支持还可以通过扩展的形式支持,自然可以实现 socket 网络编程以及常驻内存,实现长连接也是很自然的事。 CLI 模式下实现 socket 编程常见的方式有两种,一种是使用官方 sockets 扩展提供 socket 支持的方式,另一种是基于第三方扩展例如 swoole,本文主要介绍原生 PHP 的实现方式。   PHP CGI 与 CLI 示例 下面分别列出两个例子,介绍 CGI 和 CLI 两个典型模式是如何运行的。   CGI 模式示例 首先是一个 C 语言实现的服务器,监听 8080 端口,接到请求时如果请求的是指定 CGI 脚本则会通过 fp = popen(cgi_script, "r"); 以子进程的方式启动 CGI 脚本,由于使用 setenv 设置了环境变量,所以在子进程中可以读取到环境变量并做出一些计算处理。 下面就是 CGI 协议中规定的环境变量,看着是否很眼熟呢?例如 QUERY_STRING 环境变量就是 CGI 协议中规定的经过 URL-encoded 的参数: 下面实现一个最基本的 CGI server,接到请求会启动一个 PHP 子进程处理,最后接到 PHP 的输出后返回客户端 PHP 脚本,需要添加可执行权限,指定默认使用 #!/usr/local/bin/php-cgi 执行,$_GET 和 $_SERVER 都是 PHP 根据 CGI 协议从环境变量中解析出来的,最终通过 echo 输出结果,传递给 Web 服务器。 通过编译并启动 server.c 就可以访问 8080 端口,看到输出结果。   CLI 模式示例 PHP 通过 sockets 扩展提供了 socket 网络编程相关的系统调用封装,下面代码中使用的是 socket_create、socket_bind、socket_listen 、socket_accept 、socket_read、socket_write、 socket_close 等一系列 socket 函数实现的 TCP 长连接服务 服务端测试 客户端测试 除此了直接使用 socket 相关函数之外,PHP 还提供了以 stream 方式处理 socket 的一系列函数,如 stream_socket_server 相当于整合了 socket_create、socket_bind、socket_listen 函数。   Workerman 的实现 Workerman 是一款高性能 PHP 应用容器,是一个典型的基于 PHP socket 的以 CLI 模式运行的应用容器,结合 IO 多路复用和多进程达到了相当不错的性能。下面就看看 Workerman 的核心部分是如何实现的。 以下代码来自 Workerman 4.1.0 版本,只展示了核心部分。 Workerman 入口函数是 runAll 在 initWorkers 函数中初始化 server 实例,其中会根据 reusePort 属性判断是否要在主进程中调用 listen 初始化 socket。 reusePort 属性相当于 socket 的 SO_REUSEPORT 选项,表示是否开启端口重用,这个选项涉及到惊群问题。 默认没有开启 SO_REUSEPORT,那么主进程会在 initWorkers 函数中主动调用一次 listen 函数创建 socket,之后在 forkWorkers 函数中 fork 出子进程,子进程会继承这个 socket,并在其之上进行事件循环的阻塞等待。之后当客户端请求到来时,所有子进程都会被唤醒尝试去 accept 客户端连接,但最终只有一个子进程可以 accpet 成功,其他子进程只能重新阻塞挂起,这种现象就是惊群,频繁且大量的进程状态切换会浪费系统资源。 而如果开启 SO_REUSEPORT 那么主进程中不会调用 listen,而是在 forkOneWorkerForLinux 时由每个子进程各自创建 socket 并分别在自己的 socket 上进行事件循环,由于开启了端口重用,所以操作系统运行不同进程监听相同端口。当客户端请求到来时,操作系统会以负载均衡的方式唤醒其中一个子进程处理请求,这样就避免了惊群问题导致的性能损耗。 最终在 run 方法中创建并启动事件循环 workerman 在 CLI 模式下结合多路复用 IO 和事件循环,并采用多进程模式运行,可以较好的支持高并发长连接场景。   PHP 不适合干这个? 可能有的人会说 PHP 不适合干这种活,不过在我看来适不适合应该以成本为前提。Web 应用属于典型的 IO 密集型应用,这种场景下使用这种方案已经可以应对大部分业务规模,如果团队是 PHP 为主语言那么使用这个方案成本是最低的而且效果也相当不错,或者说在业务发展到瓶颈之前这个方案一般不会先遇到瓶颈,如果遇到了那么首先恭喜你的业务取得了长足进步,其次应该考虑的是通过架构的方式来解决更大规模问题,例如进行服务化和分层化等等。 总的来说 PHP 不仅仅停留在 FPM,也绝不是低性能的代名词,结合业务场景和团队实际情况,采用合适的 PHP 解决方案不仅能达到不错的效果,开发和维护成本方面也具有一定优势。

2024/8/13
articleCard.readMore

以定时器为例研究一手 Python asyncio 的协程事件循环调度

在使用 Python 的 asyncio 库实现异步编程的过程中,协程与事件循环这两个概念可以说有着千丝万缕的联系,常常是形影不离的出现,如胶似漆般的存在,asyncio 库到底是如何调度协程的? 下面以 Python 3.8 中的 asyncio.sleep 定时器为例研究一手 asyncio 的源码实现。 几个主要的概念 首先需要对 asyncio 中的几个主要函数和模块做一个初步认识: asyncio.run 是启动事件循环的入口,接收一个协程作为参数。 asyncio.BaseEventLoop 就是事件循环基类了,子类常用的是 _UnixSelectorEventLoop,但核心调度逻辑都在基类中,其中最主要的是 run_forever 函数用来启动事件循环;另一个主要的函数是 create_task ,用来创建一个 Task 对象并放到事件循环中,准备在下一次循环时执行。 asyncio.events.Handle 和 asyncio.events.TimerHandle 是放到 loop 中的处理对象,其中 _callback 属性保存的是一个回调函数,处理对象执行时调用的就是这个函数,回调函数参数放在_args 属性中。 asyncio.futures.Future 作为一个事件在未来完成的占位符,当事件完成后可通过 Future.set_result 方法将事件的结果设置进去。 asyncio.tasks.Task 是 Future 类的子类,可以理解为是对协程的包装,在 Future 基础上增加了启动协程和恢复协程的能力,主要逻辑在 Task.__step 函数中。   从简单例子开始 先从最简单的一段代码开始 这段代码启动一个 main 协程,协程输出两行内容后完成结束,这里先不加入任何 await 异步操作,主要看一下事件循环是怎样初始化和启动的,只保留了关键代码。   loop 的初始化 首先看 asyncio.run 函数,内容比较简单,初始化一个事件循环 loop,然后调用 loop.run_until_complete(main) 启动并传入 main 协程。   Task 的初始化 接着来到 asyncio.base_events.BaseEventLoop.run_until_complete,首先调用了 asyncio.tasks.ensure_future 函数,目的是将传入的 main 协程转换成一个 Task 对象,在创建 Task 的过程中会将 Task 对象加入到 loop 的队列中,之后调用 self.run_forever 启动事件循环。 确切的说应该是将 Task.__step 函数包装到 Handle 对象中,之后加入到 loop 的队列中,稍后会看到这个细节。 再看一下 Task.__init__,其中 _coro 保存了传入的协程 coro 对象,实际上可以将 Task 视为一个协程的包装,在初始化的后面调用了 loop.call_soon(self.__step, context=self._context) 函数,将 Task 对象自己的 __step 函数加入到 loop 队列,当 loop 启动后便会执行这个函数。 再看一下 loop.call_soon 做了什么,接受一个 callback 参数,在这里就代表 Task.__step,接着会调用 _call_soon 函数,在 _call_soon 函数中初始化了 events.Handle 对象,然后将 handle 对象加入到 loop._ready 队列中。 在看一眼 Handle 的初始化,主要就是将 callback 保存下来,并且用 args 表示 callback 的参数。 Handle 的一个主要的函数是 _run,当 loop 启动后会从 loop._ready 队列中取出 Handle 执行,执行的就是 _run 函数,_run 函数中 self._context.run(self._callback, *self._args) 其实就是在原有 context 环境下执行回调函数并传入 args 参数。 到这里先总结一下,通过 asyncio.run(main()) 添加了一个协程,然后将协程 main 包装成 Task,并将 Task.__step 包装成 Handle 放到 loop._ready 队列中,接下来就是真正启动 loop 了。   loop 的启动 asyncio.base_events.BaseEventLoop.run_until_complete,在封装完 main 协程后会先添加一个回调函数 _run_until_complete_cb,回调函数会在 main 协程执行完后执行,内容就是将 loop 设置成关闭。 接着的 run_forever 函数就是启动 loop 了。 run_forever 中做了一些初始检查和设置,然后进入 while 循环并在循环中调用 _run_once,_run_once 就是一次事件循环的核心调度逻辑了。   loop 调度的核心逻辑 核心调度逻辑在 _run_once 中。loop 主要有两个队列存放协程任务对应的 Handle,一个是 _scheduled 用来存放定时类协程,它是一个最小堆实现的优先队列,例如使用 asyncio.sleep 就会存进去一个 TimerHandle 对象;另一个是 _ready 用来存放准备好执行的协程,而 _scheduled 中有准备好的协程会取出来放入 _ready 中,loop 最终执行 Handle 都是从 _ready 中取出的。 _run_once 中做的事情分四个部分,第一部分是清理 _scheduled;第二部分是调用多路复用 IO 并处理就绪的描述符;第三部分是将到期的 _scheduled 转移到 _ready;第四部分遍历 _ready 并逐一启动处理函数 handle._run; Handle._run 没啥说的,直接调用 Handle._callback,并且将 Handle._args 作为参数传进去。 还记得 loop 是怎么启动的吗?将 main 协程包装成 Task,在创建 Task 时将 Task.__step 作为 callback 生成了一个 Handle 并放到了 loop._ready 中,所以这里 Handle._run 其实执行的就是根据 main 协程生成出来的 Task.__step。Task.__step 是协程启动和协程暂停恢复的关键   协程的启动 Task._coro 属性保存了协程,通过 result = coro.send(None) 启动协程,由此进入到 main 协程中,打印出 main start 和 main end。 之后 main 协程结束,抛出 StopIteration 异常,调用 super().set_result(exc.value) 给 Task._result 设置结果并将 _state 标记成 _FINISHED,之后调用 __schedule_callbacks 触发 Task 上注册的回调函数,在这里 mian 协程注册的就是 _run_until_complete_cb 用来结束 loop 的,将回调函数放在传给 loop.call_soon 等待下一轮事件循环来触发。 到这里就可能看到一个协程是如何传给 loop 并启动的了,也知道了 loop 的大概流程。下面在 main 中加入 asyncio.sleep 看看定时器是如何调度的。   asyncio.sleep 如何定时 main 中加入一个 asyncio.sleep 看看定时是如何实现的 loop 的初始化和启动还是一样的,直接看看 Task.__step 是如何调度的,其中调用 result = coro.send(None) 会启动协程,首先输出 main start,然后调用 asyncio.sleep(3)   协程的挂起 首先常见一个空的 Future 对象 future,然后调用的 loop.call_later(delay, futures._set_result_unless_cancelled, future, result),然后一路向下调用 loop.call_at,最后生成了一个 TimerHandle 对象 push 进 loop._scheduled 堆中。 TimerHandle 其实就比 Handle 多了个 _when 属性表示何时可以恢复运行,当时间到了会调用 TimerHandle._run 执行 TimerHandle 的 callback,也就是 _set_result_unless_cancelled(future, result) 用来给 future 设置结果。 asyncio.sleep 的函数签名是 asyncio.sleep(delay, result=None),一般不传第二个参数所以结果是 None,如果传的话之后会将结果设置到 future 对象里面。 asyncio.sleep 函数的最后将 future 返回并挂起自己,控制权又交还给 Task.__step 中 result = coro.send(None) 的位置,result 接到的就是 future 对象。 result 接到 future 后向下执行到 result.add_done_callback(self.__wakeup, context=self._context) 给 future 设置一个回调函数 Task.__wakeup,到这里本轮循环就结束了。 到目前为止 loop 的状态是 _scheduled 堆中有一个 TimerHandle 对象,对象的 _when 表示剩余启动的秒数,对象的 _callback 指向的是 futures._set_result_unless_cancelled 参数是一个 future,这个 future 的 callbacks 回调列表中有一个 main 协程生成的 Task.__wakeup。   协程的恢复 本轮循环结束,下一轮循环时会检查 loop._scheduled 发现 TimerHandle 已经到期,将其放到 loop._ready 队列中,紧接着就取出执行 TimerHandle._run,也就是执行 futures._set_result_unless_cancelled(future, None),其实就是给 future 设置结果、标记完成、执行 future 的回调函数。 还记得 future 是怎么来的以及 future 里面是啥吗?future 是在 asyncio.sleep 时生成并通过 await 返回的,返回给 Task.__step 后通过 add_done_callback(self.__wakeup) 为其添加了一个回调函数。 所以到此为止干的事儿就是遍历 future 的 callbacks 逐一通过 loop.call_soon() 添加到 loop 中,等待下一轮事件循环执行,这里添加的就是 main Task 的 __wakeup 函数。 进入下一轮循环,loop._ready 中有一个 Handle,其内部的 _coro 代表的是 main Task 的 __wakeup,取出来执行 Handle._run 实际上就是执行 main Task.__wakeup。 __wakeup 也很简单就是确认 future 是已完成状态并调用 __step,控制权有交给了之前挂起的 main Task。 当 Task.__step 再一次执行到 result = coro.send(None) 时,便会恢复之前的 sleep 协程接着执行 return,回到了 main 函数中,继续执行并输出 main end最后完成,抛出 StopIteration 异常,被 Task.__step 捕获,整个协程结束,之后事件循环做收尾工作也关闭,事件循环也关闭,到这里整个程序就结束了。   总结 asyncio 中的定时通过 asyncio.sleep 实现,原理是在事件循环中维护一个最小堆实现的优先队列 _scheduled,其中保存的都是定时任务处理对象 Handle,越早到期 Handle 就会越早被取出来并加入到 loop._ready 队列,在下一轮循环时取出并从挂起的位置恢复执行。 由于协程代码在执行时会切换控制权导致代码逻辑跳来跳去,有时会被绕晕,借助定时器的调度可以让整个事件循环的逻辑更加清晰。

2024/8/10
articleCard.readMore

理解同步异步与阻塞非阻塞——傻傻分不清楚的终极指南

同步异步与阻塞非阻塞这两组概念在 IO 场景下非常常见,由于他们在表现出来的效果上很相似,很容易造成混淆和困扰,要想理清楚这两组概念首先需要认识到这两组概念强调的是不同维度的事。 同步异步强调的是两个操作之间的顺序关系,两个操作之间是有序的还是无序的; 阻塞与非阻塞强调的是一个调用发起后调用发起方的行为,是被动等待还是主动获得执行权; 下面以 Python 代码为例介绍这几个概念。   同步关系与异步关系 因为同步异步强调的是两个操作之间的顺序关系,所以加上关系俩字更好理解和区分。 同步 "Synchronous" 这个词源自希腊语 "syn"(意为"一起")和 "chronos"(意为"时间"),它的字面意思是"在同一时间发生"。在通信和计算机领域中,“同步”则有两层含义,一个是"一起发生",另一个是"按顺序进行",这两层含义缺一不可,它意味着多个操作按照预定的顺序和时间协调进行,从而保持整体的一致性和协调性。 这里可以联想一下并发控制中为什么存在“同步互斥”这样的概念?目的就是为了协调多进程访问临界区时,必须等临界区中的 A 进程退出临界区后,B 进程才可以进入临界区执行,本质上是将并行(异步)关系变成了串行(同步)关系。 再回想一下 SQL 隔离级别中最高级别串行化 Serializable 是不是更能理解了?同样是将并行(异步)关系变成串行(同步)关系。   同步关系 (Synchronous) 同步指的是某个操作 A 必须等待前一个操作 B 完成之后才能开始,也就是说 A 在 B 完成之前不会启动。 也可以描述为 A sync before B,意味着操作 A 在操作 B 之后按顺序执行,并且 A 必须等待 B 完成后才开始。 说白了同步意味着 A 和 B 之间的执行有先后顺序关系,中国有句古话:先穿袜子再穿鞋,先当孙子再当爷,讲述的就是这个道理 😁。 同步例子,其中 task_A 和 task_B 是同步关系,只有 task_A 执行完了task_B 才能执行。 输出   异步关系 (Asynchronous) 在异步操作中,操作 A 不需要等待前一个操作 B 完成之后才能开始,A 和 B 可以同时进行,或者 A 可以在等待 B 的过程中执行其他操作。 可以描述为 A async with B 意味着操作 A 和操作 B 可以同时执行或 A 不需要等待 B 完成。 说白了 A 和 B 的执行没半毛钱关系,你在穿鞋的同时也可以喘气儿,先喘再穿还是先穿再喘甚至边穿边喘都可以,怎么喜欢怎么来,互不影响。 异步例子,task_A 和 task_B 同时执行,都不需要等待对方,各自爱怎么跑怎么跑。 输出   阻塞调用与非阻塞调用 阻塞和非阻塞重点强调的是调用方在发出调用后的行为,为了更好的理解这一对儿概念,可以在阻塞和非阻塞后面加上“调用”俩字,变成阻塞调用和非阻塞调用。   阻塞调用 (Blocking) 阻塞调用发出后,调用方会挂起等待,当被调用方执行完成并返回结果后,调用方才会被唤醒并接到结果继续执行之后的操作。 说白了阻塞调用就是发出调用后傻等着,整个进程都等在调用发出这一行。 代码示例,下面代码中 blocking_operation 内部有一个耗时操作,main 函数中进行阻塞调用,blocking_operation 不返回就一直在这等。 输出   非阻塞调用 (Non-blocking) 非阻塞调用发出后,调用方不会挂起等待,而是立即返回,之后可以选择继续别的操作。被调用方在后台(可能以各种形式实现)处理原本的业务逻辑,处理完成后可以通过回调、信号等机制通知调用方。 说白了非阻塞调用就是发出调用后马上返回,无论能不能得到想要结果都义无反顾的返回,啪的一下很快啊。至于结果没拿到怎么办?可以循环重试啊。 代码示例,下面代码中 non_blocking_operation 中有一个耗时操作,但调用时以非阻塞方式调用,立刻返回并继续执行 main 函数后面内容而不是一直等待。 输出   两两结合 现在说说这两组概念的两两结合,设想这样一个场景,在一个主流程 main 中希望调用 read 发起 IO 读取数据,根据 main 和 read 的顺序关系以及 main 发出调用后的状态可分为如下几种情况:   同步阻塞 同步意味着 main 只有在 read 完成后才能继续执行,同步意味着有序; 阻塞意味着只要 read 不返回则 main 就必须挂起等待。下面是一段示例: 输出   同步非阻塞 首先说结论这种模式很少有实际应用。 同步意味着 main 只有在 read 完成后才能继续执行,同步意味着有序; 非阻塞意味着 read 调用会马上返回所以 main 可以立刻获得 CPU 时间片得以继续执行,但由于 main 和 read 之间是同步关系,main 必须等待 read 真正完成后才能继续执行,那么 main 只能主动放弃执行进而等待类似回调机制的通知。 因为 main 已经获得了执行权但却又不真正执行,等同于浪费了 CPU 的调度和时间片,所以这种情况在实际应用中很少就不写例子了,实际上我没想到有什么典型的例子可以写。   异步阻塞 首先还是说结论这种模式的应用也非常少。 异步意味着 main 与 read 的执行互不影响,相互之间并不存在谁要等谁的情况,可以各自愉快滴运行,异步意味着无序。 阻塞意味着 main 调用 read 后必须等待 read 的结果返回,实际上这也浪费了 main 和 read 之间的异步关系,本可以并行执行的,现在只能挂起等待,所以实际应用并不多,也没有特别好的例子可写的。   异步非阻塞 异步意味着 main 与 read 的执行互不影响,相互之间并不存在谁要等谁的情况,可以各自愉快滴运行,异步意味着无序。 非阻塞意味着 read 调用后可以马上返回,同时由于二者是异步关系,所以可以实现 main 和 read 各自都可以继续向下执行,并发效率是最高的。 输出   异步非阻塞的应用价值 曾几何时江湖上流传着一个名为 c10k 的问题,说的是服务器如何应对 10000 个网络连接的场景。这其中的主要矛盾是人民群众日益增长的高质量互联网应用的需要与落后的服务器并发能力之间的矛盾,因为 fork 多进程模型在处理大量连接时资源消耗是非常严重的,通过增加服务器集群数量已经不能解决根本问题,迫切需要一种新的解决方案的出现,异步非阻塞就是在这样的背景下提出来的。 最早接触异步非阻塞是 Python 的 tornado 框架,记得当时 tornado 的官网上还有 c10k 问题的介绍,主打的就是一个支持高并发高性能的网络框架,可以完美应对 c10k,tornado 一度成为了 Python Web 领域高性能的代名词。 不过经过这么多年的发展,结合多路复用 IO 以及各种语言分别加入了异步编程特性,c10k 已经不再被视为一个问题,反而成为了高性能高并发技术的里程碑。 下面就以 Python 为例写一段代码,体现异步非阻塞的价值所在。 Python 在 3.5 版本之后引入了 async await 等一系列原生支持的协程语法,之前想要实现协程一般使用 yield 结合一些装饰器,写起来心智负担比较重,有了 async await 通过协程实现异步编程就简单多了。 这段代码使用 aiohttp 库实现了一个 http server,其中 handle 方法通过 sleep 模式执行一段 IO 操作, time.sleep(5) 表示以同步方式执行,await asyncio.sleep(5) 表示以异步方式执行。 启动服务 再编写一个并发请求的脚本,可以同时发起 http 请求,观察请求执行时间可以看出,同步和异步两种方式的区别,其中 time 命令可以统计 curl 执行时间,输出的 real 表示耗时秒数。 脚本启动后可以观察使用同步和异步两种方式的耗时的不同 能看到同步方式下第一次请求耗时 5s 而第二次请求耗时 10s,也就相当于两个并发请求被串行化了。在异步方式下两次请求分别耗时 5s,互不影响。 异步非阻塞结合协程在高并发场景下,可以花费较少代价便能够支持大量网络连接,这是非常有价值的。   总结 想要彻底搞清楚同步和异步、阻塞和非阻塞,就要明确他们分别是从两个维度出发强调的不同概念。前者强调的是两个操作之间的顺序关系,后者强调的是调用方发出调用后的行为,搞清楚这两个维度才能够清晰的理清楚他们之间的关系。

2024/8/3
articleCard.readMore

Go 静态编译及在构建 docker 镜像时的应用

Go 语言具有跨平台和可移植的特点,同时还支持交叉编译,可以在一个系统上编译出运行在另一个系统上的二进制可执行文件,这是因为 Go 在编译时支持将依赖的库文件与源代码一起编译链接到二进制文件中,所以在实际运行时不再需要依赖运行环境中的库,而只需要一个二进制文件就可以运行,在构建 docker 镜像时就可以利用这个特点,实现减小镜像大小的目的,下面逐步介绍这中间涉及到的关键点。 链接库 什么是链接库,为什么要有链接库 链接库是高级语言经过编译后得到的二进制文件,其中包含有函数或数据,可以被其他应用程序调用,链接库根据链接方式的不同分为静态链接库和动态链接库。 以 C 语言标准 ISO C99 为例,它定义了一组广泛的标准 I/O、字符串操作和整数数学函数,例如 atoi、printf,scanf、strcpy 和 rand。它们在 libc.a 库中,对每个 C 程序来说都是可用的。ISO C99 还在 libm.a 库中定义了一组广泛的浮点数学函数,例如 sin、cos 和 sqrt。 如果没有链接库,那么当开发者需要用到上述标准函数时有下面几种方式实现,第一种是开发者自己实现一遍,可想而知这样开发效率很低,而且容易出错;第二种是编译器解析到使用了标准函数时自动生成相应的代码实现,这种方式将给编译器增加显著的复杂性,而且每次添加、删除或修改一个标准函数时,就需要一个新的编译器版本,比较繁琐;第三种则是将标准函数的实现打包到一个标准函数目标文件中,例如 libx.o,开发者可以在编译时自行指定使用哪个标准函数目标文件。 相较而言第三种的思路更好一些,因为这种方式将编译器和标准函数的实现分离开,降低了编译器的复杂度,同时又能在标准函数的实现发生变化时以较低成本实现替换,链接库就是基于这种方式而来的。 链接库的两种类型 编译过程中编译器将源代码编译成目标文件,一般以 .o(object) 作为扩展名,之后链接器将多个目标文件链接成可执行文件或链接库文件,链接库根据被使用时的方式的不同分为静态链接库和动态链接库。 Linux 平台上静态库一般以 .a(archive) 为扩展名,动态库一般以 .so(shared object) 为扩展名; Windows 平台上静态库一般以 .lib 为扩展名,动态库一般以 .dll(dynamic link library) 为扩展名; 静态链接库是将相关函数编译为独立的目标模块,然后封装成一个单独的静态库文件。编译程序时可以通过指定单独的文件名来使用这些在库中定义的函数。比如,使用 C 标准库和数学库中函数的程序可以用如下的命令行来编译和链接: 而在链接时,链接器只会复制被用到的目标模块,而并不会复制整个库的内容,这就减少了可执行文件在磁盘和内存中的大小。 静态链接库也有一些缺点,首先是静态链接库是在编译链接过程中被复制到可执行文件中的,当静态链接库有更新时,应用程序必须重新执行编译链接得到新的可执行文件。第二是几乎每个 C 程序都会用到标准 I/O 函数,比如 printf 和 scanf,这些函数的代码被重复的复制到每个运行进程的文本段中,这对于内存来说是一种浪费。 动态链接库避免了上述问题,应用程序在编译时只记录一些动态链接库的基础信息,在加载应用程序但还没有运行时会将依赖的动态链接库中的函数与内存中的程序链接起来形成一个完整的程序,所有引用同一个动态链接库的可执行文件共用这个库中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。 使用链接库 使用静态链接库 下面用 C 语言编写两个函数,并分别生成静态链接库和动态链接库,最后在另一个程序中使用生成的链接库。 addvec.c 文件,其中 addvec 函数实现两个向量数组的相加 multvec.c 文件,其中 multvec 函数实现两个数组向量的相乘 定义头文件 vector.h main2.c 用来测试使用链接库 首先编译出两个库函数的目标文件 得到两个目标文件 addvec.o 和 multvec.o,接着将两个目标文件链接成静态库,ar 命令是用来处理静态链接库的,也就是归档文件 archive 得到静态链接库 libvector.a,最后编译链接应用程序和动态链接库生成可执行文件,其中 -static 参数用来生成静态链接程序 最后得到可执行文件 prog2c 并运行 当链接器运行时,它判定 main2.o 引用了 addvec.o 定义的 addvec 函数符号,所以复制 addvec.o 到可执行文件。因为程序不引用任何由 multvec.o 定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制 libc.a 中的 printf.o 模块,以及许多 C 运行时系统中的其他模块。 下面是使用静态链接库生成可执行文件的图示:   使用动态链接库 再看一个动态链接库的例子,代码还是一样,只是在生成链接库和编译链接的时候不太一样。使用 gcc 生成动态链接库,其中 -shared 参数表明生成共享的链接库,-fpic 参数表明生成位置无关代码(position-independent code),位置无关代码可以理解为是库中的函数都没有确定下来在内存中的具体的绝对位置,而是使用相对位置表示,只有在被链接到应用程序中才被确定最终在内存中的位置。 得到动态链接库 libvector.so,之后编译链接生成可执行文件 得到可执行文件 prog2l 并运行 创建完可执行文件后,其实并没有任何 libvector.so 的代码和数据节真的被复制到可执行文件 prog2l 中。链接器仅仅是复制了一些重定位和符号表信息,它们使得运行时可以解析对 libvector.so 中代码和数据的引用,在程序加载时动态链接才真正完成。 下面是动态链接库的图示:   在程序运行中加载链接库 此外还可以在应用程序运行过程中加载指定动态链接库,但这里不展开,只列出一个典型的例子,下面例子是在应用程序运行中加载调用 libvector.so 库: 编译   动态编译与静态编译 编译应用程序时如果使用静态链接库则被称为静态编译,如果使用动态链接库则被称为动态编译。静态编译是在编译时就将依赖的静态链接库复制到可执行文件中,这样在应用程序运行起来后无需依赖外部的库,只需要单一的可执行文件即可运行,但缺点是应用程序体积相对较大,程序运行的越多重复占用的内存浪费越多。 动态编译则相当于按需加载,动态编译有好处也有弊端,好处是应用程序只需要链接用到的目标模块,这使得应用程序的体积更小,运行起来之后内存占用更低。而弊端则是如果应用程序所在的运行环境中缺少依赖的动态链接库则会导致无法正常运行。 Go 静态编译和动态编译例子 静态编译 Go 支持跨平台和可移植特性,默认使用静态编译 编译后可以通过 ldd(List Dynamic Dependencies) 命名查看可执行程序所依赖的动态链接库: not a dynamic executable 表示没有依赖任何的动态链接库。 动态编译 但并不是所有情况下都不需要依赖外部库,例如对于很多经典的 C 语言函数库来说,编程语言没必要自己重新实现一遍,需要用到时直接调用 C 语言函数库即可。下面的 Go 程序中使用了 net/http 包,其中关于网络的处理很多都是依赖 C 语言的动态链接库: 编译后用 ldd 查看 能看到输出了一些动态链接库,例如 libresolv.so.2 就是用于域名解析的库,而 libc.so.6 则是标准 C 库,含有大部分 C 函数。 下面介绍 Go 如何指定进行静态编译   Go 强制进行静态编译 如果希望将上述代码编译后运行在另一个系统中,为了保证可移植性,应该尽量使用静态编译,如果想要强制使用静态编译有两种方式。 通过关闭 CGO 实现静态编译 先介绍 CGO CGO 是 Go 开发工具链中自带的工具,CGO 是使 Go 语言和 C 语言相互调用的桥梁。如果在 Go 代码中包含 import "C" 并且开启 CGO,那么在 go build 编译时就会通过 CGO 来处理 C 代码,生成能够让 Go 调用 C 的桥接代码,然后交给 gcc 编译得到 C 语言的目标文件,之后再编译 Go 代码得到 Go 语言的目标文件,最终将 Go 和 C 目标文件通过链接器链接到一起得到最终的可执行文件。 CGO 通过环境变量 CGO_ENABLED 控制是否启用,默认为 1 表示启用,0 表示关闭。 下面一段代码展示利用 CGO 实现 Go 调用 C 函数的功能,主要有两个文件 hello.go 和 hello.c hello.go hello.c 查看环境变量 编译运行 可以看到用 Go 调用 C 语言函数的运行效果。 通过关闭 CGO 间接实现静态编译 按照这个思路,如果关闭 CGO 之后再编译之前的 server.go 的应用代码,Go 编译器由于无法启用 CGO 也就无法生成 Go 和 C 之间的桥接代码,无法利用 C 函数库,只能使用纯 Go 实现的函数,从而实现静态编译效果。下面就是关闭 CGO 后编译的 server.go 在 go build 前指定 CGO_ENABLED=0 来关闭 CGO,最后得到的可执行文件可以看到不再依赖动态链接库,实现静态编译。 通过链接参数实现静态编译 假如我希望在代码中调用 C 函数,但又希望执行静态编译应该怎么做?也就是说我必须开启 CGO 但又希望进行静态编译。go build 有一个 -ldflags 参数表示传给链接器的参数,参数中 -linkmode 控制使用 Go 内部自己实现的链接器 internal(默认值),还是外部链接器 external,例如使用 gcc clang 等。如果代码中只需要 net, os/user, runtime/cgo 等包则使用 internal,否则使用 external。-extldflags 表示传给外部链接器的参数,这里是 -static 表示使用静态链接方式。 得到编译后的可执行文件 server,通过 ldd 查看表明这是一个静态链接的可执行文件。   利用静态编译减小 docker 镜像体积 静态编译后二进制文件可移植性较好,只需要一个单独的文件便可以运行,并且由于编译时的环境要求与运行时的环境要求不同,运行时环境中不要求有编译链接等工具,所以可以利用这个区别在构建 docker 镜像时只需要保留能够支持可执行文件运行的最少资源即可,从而缩小镜像体积。 使用两个 Dockerfile 分别构建 下面有两个 Dockerfile,第一个是 build.Dockerfile,主要是执行静态编译指令编译出可执行文件 server: 构建镜像 之后创建一个容器,测试功能正常: 此时查看一下镜像大小为 796MB。 现在测试将可执行文件转移到另一个容器环境中单独执行,首先把在第一个镜像中编译好的 server 复制出来到宿主机上。 然后在第二个名为 run.Dockerfile 的 Dockerfile 中把 server COPY 进去 构建镜像 启动容器并测试功能正常: 此时对比一下两个镜像,go_web_build 有 796MB,而 go_web_run 仅有 15.4MB,大幅缩小了镜像的大小。 不过这样做还是有点繁琐,需要编写两个 Dockerfile 同时还要手动复制可执行文件,而 docker 的多阶段构建可以简化这个过程。 使用 docker 的多阶段构建 docker 多阶段构建(multi-stage build)可以在一个 Dockerfile 中编写上述两个镜像构建过程,使用 FROM 指令表示开始一个阶段的构建,第一阶段构建用来编译得到可执行文件,在第二阶段构建时可以将上一个阶段中产出的可执行文件 COPY 到当前构建的镜像中,从而实现与上述效果相同的减少镜像体积的目的。 现在使用多阶段构建结合 Go 的静态编译做一个实验,下面是名为 mutil_stage.Dockerfile 的 Dockerfile 文件: 构建镜像 启动容器运行测试正常: 查看镜像可以看到 go_web_mstage 也是 15.4MB,这样就实现了在一个 Dockerfile 中声明两个镜像并且保持镜像体积相对较小。   总结 文中涉及到的相关概念比较多,这里做一个要点总结。首先介绍了链接库的概念以及静态链接库和动态链接库的区别,接着介绍了 Go 的静态编译和动态编译以及如何实现静态编译,最后举了一个实际例子,使用 Go 的静态编译结合 docker 的多阶段构建实现了减小镜像体积的效果。

2024/7/16
articleCard.readMore

通过单例探究 Go 可见性与内存屏障

Go 实现单例的 5 种方式 第一种,利用包的 init 函数 init 函数在包初始化时自动执行,这意味着它在程序开始执行前,由于 init 函数是由 Go 运行时自动调用的,并且在程序生命周期中只会被调用一次,它可以被用来初始化单例。 输出 代码中由于 init 函数保证在包初始化时自动执行且只执行一次,这确保了 instance 只被初始化一次。 不过这种方式不提供延迟初始化(lazy initialization)的能力,也被称为是饿汉模式,这意味着无论这个单例是否需要,它都会在程序启动时被创建。 而后面几种方式则具备延迟初始化能力,也被称为懒汉模式。   第二种,非并发安全的普通方式 判断实例对象是否为 nil,如果没有实例化则进行实例化。 这种方式的问题是并发环境下会重复创建,即非并发安全。 可以在 getInstance 函数中稍微 sleep 一下,就可以明显的看到结果中打印出多个初始化的输出。 输出   第三种,加锁保证并发安全 上来就先加锁,这样可以保证并发安全。 这种做法虽然可以实现并发安全的单例,但效率相对低一些,因为每次都要加锁解锁,即使已经实例化之后还是要白浪费一次加锁解锁。 输出   第四种,双重检测锁 double-checked locking 这种做法相当于在前一种做法的最外层再加一次 nil 判断,目的就是为了避免如果已经完成实例化之后,后续的加锁解锁都是无效操作。 这种做法实现了功能的同时,性能也有了保证,但写法稍微有点冗余。 输出   第五种,利用 sync.Once sync.Once 的 Do 方法接收一个无参数无返回值的函数,并保证该函数在并发环境下只执行一次。 这样就可以利用这个特性实现单例,这个写法功能和性能都有保证,而且代码也比较简洁。 输出 这种写法是最安全且效率最高的。   sync.Once 的实现原理 顺便看看 sync.Once 内部是怎么做的, sync.Once 结构体如下: Do 函数 首先 Do 函数中使用 atomic.LoadUint32(&o.done) 原子读取 done 属性来验证函数 f 是否已经执行了,这一步使用原子操作而非互斥锁,目的也是为了提高性能,可以尽量少的避免加锁。这一步也可称之为快速路经检测。 接下来如果发现 done 的值是 0 ,则表示函数 f 还没有执行,那么将调用 doSlow 函数,注意这一步可能存在多个 goroutine 同时进入 doSlow 的情况,因为 atomic.LoadUint32(&o.done) 可以被多个 goroutine 先后执行,得到同样的结果,即 done 是 0。   doSlow 函数 进入 doSlow 之后可以称之为慢速路径检测,首先会上一个互斥锁 o.m.Lock() ,然后在对 done 的值做一次检测,这一步就是双重检测锁定,跟前面介绍的一样,因为可能有并发多个 goroutine 进入 doSlow,而只有一个 goroutine 成功获得互斥锁并设置 done 的值,当这个 goroutine 返回并释放锁之后,其他 goroutine 会再一次检测 done 的值,避免多次执行 f。 注意这里使用的是 if o.done == 0 而非原子操作,这是因为 Mutex 在提供同步保证的同时,还隐含的提供了可见性保证。   可见性与内存屏障 可见性 互斥锁 Mutex 在提供同步保证的同时,还隐含的提供了可见性保证,即在互斥锁保护之下的数据写入,在锁释放之后,该写入对其他尝试获取同一把锁的 goroutine 是可见的。 对应到前面代码中,第一个成功获取锁的 goroutine 设置完 done 之后并释放锁,这次数据的变更对其他 goroutine 是可见的,之后其他的 goroutine 在查看 if o.done == 0 时就会发现值变成了 1 。   内存屏障 在这背后涉及到内存屏障的概念,内存屏障 Memory Barrier 也称为内存栅栏 Memory Fence,是一种同步机制,用于控制指令和内存操作的执行顺序。在多处理器系统中,由于各种优化技术,如缓存、指令重排等,不同处理器上的线程可能看到内存操作以不同的顺序发生。内存屏障的作用是确保在屏障之前的所有内存操作如读写,在屏障之后的操作开始之前完成,并且对所有处理器可见。   底层硬件方面的支持 大多数现代处理器架构如 x86、ARM、PowerPC 等,提供了特定的指令来实现内存屏障。这些指令能够直接影响处理器如何处理接下来的内存操作,确保在内存屏障指令之前的所有操作都完成,并且结果对所有处理器核心可见,然后才执行屏障之后的操作。例如,在 x86 架构中,MFENCE、LFENCE、 SFENCE 等指令用于不同类型的内存屏障。   操作系统方面的支持 操作系统通过提供库函数或系统调用来支持内存屏障操作,特别是在处理器指令级别之上。例如,Linux 提供了如 mb(), rmb(), wmb() 等函数,分别用于全屏障、读屏障和写屏障。   语言层面的实现 Go 中的互斥锁 sync.Mutex 不仅提供互斥,还隐式地提供内存屏障。当一个 goroutine 释放一个互斥锁时,实际上是在执行一个内存屏障操作。锁释放操作确保所有在该锁保护下进行的内存写入在锁被释放时对所有处理器都是可见的。这就建立了一个保证,在锁释放之后获取同一把锁的任何 goroutine 都能看到在锁保护下所做的内存修改。 同样地,当一个 goroutine 尝试获取一个互斥锁时,也会有一个内存屏障操作,确保在它获取锁之后进行的内存读取操作能看到之前已释放该锁的 goroutine 所做的所有写入。 此外 sync/atomic 包提供了一系列原子操作函数,这些函数保证了在多 goroutine 环境中的安全和有序访问共享变量。这些原子操作在底层使用处理器提供的原子指令,这些指令的实现也隐含了必要的内存屏障语义。 编译器在编译过程中负责正确地安排内存访问指令和插入必要的内存屏障指令,编译器会分析代码并会根据不同的底层环境,在必要的位置插入内存屏障指令,来保证内存操作的顺序和可见性。   同步保证意味着可见性保证 可以这样简单的理解:同步保证也提供了可见性保证,同步条件下的数据写入会被其他 goroutine 读取到。

2023/12/24
articleCard.readMore

南瓜红枣小米粥

又到了一年一度的流感季节,得了流感没食欲,可以喝一点小米粥,配上南瓜和红枣,软糯又香甜,暖胃还补气血,而且在家做粥也很方便,现在的电饭锅一般都带煮粥模式,材料备好搞里头基本就等着喝就行了。 首先小米清洗一下,再泡上 10-20 分钟,让小米吸吸水分。   泡小米的时候,就可以处理南瓜和红枣了。首先南瓜瓤剔除之后削皮,可以多削一点,南瓜皮有点硬,削的不彻底影响口感,然后切成小块,方便煮的时候化开,切好之后备用。   接下来是红枣,红枣需要清洗一下,然后靠近胡的旁边切四刀把胡去掉就可以了。   之后就可以开始煮粥了,电饭锅设置成煮粥模式。   先煮半小时,之后下南瓜,下南瓜的时候直接打开电饭锅盖就行,不需要暂停,否则电饭锅就重新计时了。   在临出锅之前十分钟左右再放红枣,不要提前放,红枣煮时间长会发苦; 另外放红枣的时候,如果南瓜没有化开,也可以用勺子帮南瓜一个忙,帮他化开。之后再关上盖子等最后倒计时结束。   来上一碗南瓜红枣小米粥,暖胃又暖身,如果感冒了没食欲不妨试一试,当然不感冒也可以试 😄

2023/12/18
articleCard.readMore

从 Go channel 源码中理解发送方和接收方是如何相互阻塞等待的

并发编程的可见性 在 Go 官网上的内存模型一文中,介绍了在 Go 并发编程下数据可见性问题,可见性是并发编程中一个重要概念,指的是在哪些条件下,可以保证一个线程中读取某个变量时,可以观察到另一个线程对该变量的写入后的值,Go 语言中的 goroutine 也适用。 一般来说可见性属于偏硬件和底层,因为涉及到多核 CPU 的 cache 读写和同步问题,开发者不需要关心细节,高级编程语言要么屏蔽掉了这些细节,要么会给出一些保证,承诺在确定的条件下就会得到确定的结果。 Go channel 有一个特性是在一个无缓冲的 channel 上发送和接收必须等待对方准备好,才可以执行,否则会被阻塞。实际上这就是一个同步保证,那么这个同步保证是如何实现的?下面看看官方文章中是如何解释的。   先 send 后 receive 文中对 channel 的描述有几个原则,第一个是 A send on a channel is synchronized before the completion of the corresponding receive from that channel. 意思是:在一个 channel 上的发送操作应该发生在对应的接收操作完成之前。说人话就是:要先发送数据,然后才能接收数据,否则就会阻塞。这也比较符合一般的认知。 并用下面一段代码举例说明,这段代码确保一定会输出 "hello, world”。 f 函数负责给变量 a 赋值,main 函数负责打印变量 a。main 函数阻塞等待在 <- c 处,直到 f 函数对 a 赋值之后并写入数据到 c 中,main 函数才被唤醒继续执行,所以此时打印 a 必然会得到结果。   先 receive 后 send? 而下面这段描述有点反直觉 A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel. 意思是在无缓冲 channel 上的接收操作发生在对应的发送操作完成之前,说人话就是:要先接收数据,之后才可以发送数据,否则就会阻塞。这句话看上去与第一条相悖,因为第一条强调发送操作要在接收完成之前发生,而这一条强调接收操作要在发送完成之前发生,这样相互等待对方的情况,不会陷入死锁状态吗? 下面的示例代码与前一个类似,区别是将 c 换成了无缓冲 channel,并把 c 的写入和读取调换了位置,这段代码同样可以保证输出 "hello, world”。 这两段话到底是什么意思?为什么要相互等待但又不会死锁? 接下来看看 runtime/chan.go 中是怎么实现 channel 的发送和接收的。   channel 的结构 首先看看 channel 的数据结构 channel 内部实现了一个环形队列,通过 qcount dataqsiz buf sendx recvx 几个部分组成。 另外 channel 还维护了两个等待队列,如果在执行 <-c receive 操作时,此时 channel 不满足接收条件,receiver 会进入 recvq 等待队列;同样的如果执行 c<- send 操作时,此时 channel 不满足发送条件,sender 会进入 sendq 等待队列。 具体看代码:   send 具体干了什么 当 main 函数执行到 c<-123456 是,会执行 runtime/chan.go 中的 chansend 函数,该函数首先会判断当前 channel c 的等待接收队列是否有阻塞的 receiver 如果有等待的 receiver 则弹出队列,调用 send 函数,其中 sg 就表示 receiver,sg.elem 表示将数据接收到哪里去,这个地址也就对应示例代码中的变量 x 的地址。 sendDirect 函数就是直接从 src 里面将数据复制到 dst 中。 回到 chansend 函数,如果没有等待的 receiver,那么会查看当前 buf 中是否有空间,如果有空间,则数据缓存到 buf 中。 如果也没有 buf 空间,那么就将 sender 本身放入到 sendq 等待队列中。 总结起来 send 操作分三部分: 如果当前 channel 上有等待的 receiver,则直接 copy 数据过去 否则如果当前 buf 有空闲空间,则将数据存在 buf 中 否则将 sender 本身加入到 sendq 等待队列中   receive 具体干了什么 相应的与发送类似,执行到示例代码中第 (3) 步接收数据时,会调用 runtime/chan.go 中的 chanrecv 函数来处理接收,同样是先看 sender 等待队列是否有阻塞的 sender 如果有等待的 sender,那么将 sender 取出来,并复制数据。 如果没有等待的 sender,那么看 buf 中有没有缓存的数据 最后如果也没有 buf 数据,那么久把自己加入到 receiver 等待队列中 recvq 总结起来 receive 操作分三部分: 如果当前 channel 上有等待的 sender,则直接 copy 数据过去 否则如果当前 buf 有缓存的数据,则将读取该数据 否则将 receiver 本身加入到 recvq 等待队列中   小结 这样一来就能够理解前面的两个原则了,在一个无缓冲的 channel 中,无论是 sender 先执行,还是 receiver 先执行,都会因为找不到对方,并且没有 buf 空间的情况下,将自己加入到等待队列;当对方开始执行时就会检查到已经有对端正在阻塞,进而拷贝数据,并唤醒阻塞的对象最终走完整个流程。 有一种说法是:sender 必须在 receiver 准备好才能执行,否则就会阻塞;而 receiver 必须在 sender 准备好才能执行,否则就会阻塞;这个说法没错,但是太笼统了,什么叫准备好?怎么算是准备好?这是比较模糊的。而看过 send 和 receive 的流程之后,就更能理解整个过程了。   为什么要有无缓冲 channel 实际上两个 goroutine 相互等待对方到达某个状态的效果,非常类似操作系统中的一种同步机制:屏障 barrier,同步屏障要求只有当所有进程都到达屏障后,才能一起执行下一状态,否则就阻塞在屏障处。 回到 channel 操作,即 sender 和 receiver 无论谁先执行,都必须等待对方也已经执行,两者才可以继续执行。就像一块电路板串联有两个开关,要想电路联通,必须两个开关都被打开才可以,而不管哪一个先打开,都必须等待另一个开关也打开,之后电流才可以接通电路也才联通。 可以将无缓冲 channel 看做是一种同步屏障,同步屏障能够让多个 goroutine 都达到某种状态之后才可以继续执行,这是带缓冲 channel 无法做到的。另外在无缓冲 channel 数据的交换更加简单快速,因为不需要维护缓存 buf,实现逻辑也更简单,运行更可靠。

2023/12/14
articleCard.readMore

始于数据,终于数据 - 读《数据密集型应用系统设计》

本书将数据密集型应用所面临的问题和挑战做了全面的解析,并且列举了常见的应对方案,在覆盖了广度的同时,对于问题和挑战背后的本质原理也有一定深度的探讨,个人认为本书称得上是程序员必读书目之一,也属于个人 2021 年度书单中技术类书籍的最佳。   第一部分、一二三四章主要讲单机环境下的数据系统设计问题 系统设计的三个原则 数据模型与查询语言 数据存储与检索 数据编码与演化   第二部分,五六七八九章主要讲多机环境下的数据存储和检索。之所以要在多机上分布数据主要由于扩展性、容错和高可用性、延迟考虑三大原因。 数据复制 数据分区 事务 分布式系统的挑战 一致性与共识

2023/12/10
articleCard.readMore

由 Go 结构体指针引发的值传递的思考

这篇笔记的思考开始于一篇帖子中提的问题:下面这段代码中,都是从 map 中取一个元素并调用其方法,为什么最后一行无法编译通过 要回答这个问题,涉及到 Go 中的几个概念,隐式引用转换和可寻址 Addressable   隐式引用转换 先看第一次调用 Write 的地方,首先 sVals[1] 返回的是一个 S 类型的值赋值给变量 s,而之所以能够在 S 类型的变量 s 上调用 *S 类型的 Write ,是因为 Go 支持隐式引用转换,这个调用的完整写法应该是: Go 隐式引用转换后可以简写成 那么为什么第二个 Write 调用无法编译通过呢?这涉及到另一个概念:可寻址与临时值。   可寻址和临时值 可寻址 Addressable 指的是能够通过内存地址来访问变量的特性。如果一个变量是可寻址的,那么你可以使用取地址操作符 & 来获取它的内存地址。 而临时值都是不可寻址的,临时值一句话概括就是表达式的中间状态,它们的生命周期很短,只在表达式计算过程中存在。临时值只有在赋值给某个变量后临时值才算完成了使命,这个过程相当于一个值被创建出来最终安家落户,有了自己的地址,之后才能询问这个值的地址是多少。 下面是几个可寻址例子 下面是几个不可寻址的例子 再回到刚才的问题,当调用 时,如果 Go 可以进行隐式引用转换,那么就应该转换成下面这种形式: 但实际上却报了下面的错误 这个错误是说不能在类型 S 上调用指针方法 Write,这说明 Go 没有将 sVals[1] 进行引用转换。为什么没有进行引用转换呢? 这里可以做一个假设,按理说 sVals[1] 的元素已经存在于内存了,也就是说应该可以被寻址的,所以应该进行隐式引用转换成功。如果没有进行引用转换,是不是说取出来的对象是一个不能被寻址的对象呢? 事实上确实是就是这样,sVals[1] 取出来的并不是原始的对象,而是原对象的一个重新生成的副本,这就涉及到另一个概念:值传递。   map 的值传递 在 Go 中,所有的函数参数和返回值都是通过值传递的,这意味着它们都是原始数据的副本,而不是引用或指针。 这个原则在 map 中也成立,从 map 中取出一个元素返回的也是该元素的副本,而并不是该元素本身。所以上述代码中 返回的是一个副本,也就是说这是一个临时值,而对于临时值是不可寻址的。所以引用转换是不可能的,最后无法编译通过报出错误。   回答最初的问题 到这里就已经可以回答前面的问题了,由于 sVals[1] 是一个临时值所以不可寻址,所以无法进行引用转换,无法将 S 类型的变量 s 转换成 *S 类型,最后导致编译错误,报出不能在 S 类型上调用 Write 方法。   为什么要这样设计 为什么 map 要返回一个副本回来,而不是返回原始对象的地址?这种设计选择是出于安全性和一致性的考虑。由于 map 可能在运行时进行重新哈希以调整大小,重哈希后元素的地址可能发生变化,所以如果支持返回地址,那么可能会在程序运行中出现错误。例如一开始持有了一个元素的地址,之后 map 发生重哈希,地址都变了,再用之前获取的地址做操作,肯定会出问题。 既然返回的是一个副本,那么想要做出修改的话就需要注意了。例如下面这段代码 可以看到在 map 中取一个元素并修改其内容并不会影响 map 中原有元素。 那么应该如何修改 map 中的元素呢? 第一种是先修改,再回写: 第二种就是 map 中存放指针类型 用指针操作赋值是完整写法应该是 (*s).Name,而 *s 是从指责中取出对象操作,自然可以赋值。   容易混淆的值传递、引用传递与值类型、引用类型 前面一直在讨论值传递,与之相对应的是引用传递。这两种传递方式决定了函数调用时参数是如何传递的: 值传递:值传递复制数据 引用传递:引用传递复制的是数据的地址 Go 采用的就是值传递,当调用一个需要参数的函数时,函数参数会复制一份,如果参数是一个指针,也会复制出来一个新的指针对象,但注意复制的是指针对象,即新旧两个指针对象已经完全独立,有各自的内存地址,但是两个指针对象内部指向的目标对象地址没有改变,如下面代码和图示: 这也证明了有种说法称 Go 支持引用传递的说法是不严谨的,这种说法认为,通过传递指针,可以实现在函数内部修改对象的效果,所以 Go 支持引用传递,而事实上这里面依旧是值传递,只不过复制的是指针本身。   除此之外 Go 中数据类型还分为值类型和引用类型,这两种类型决定了数据是如何在内存中存储的: 值类型:值类型直接存储数据,如基本数据类型(如 int、float、bool)、结构体(struct)和数组都是值类型。 引用类型:而引用类型存储的是数据的引用,如切片(slice)、映射(map)、通道(channel)等都是引用类型。 可以在 runtime/map.go 中看到通过 makemap 函数创建一个 map 对象,实际上返回的是一个 *hmap 的指针类型; 在 runtime/chan.go 中可以看到通过 makechan 创建 channel 时返回的是一个 *hchan 指针类型; 在 runtime/slice.go 的 makeslice 返回的直接就是一个指针 unsafe.Pointer 这些都证明了上述几个类型都是引用类型,也就意味着这些类型作为函数参数传递时复制的都是指针。 无论是值类型还是引用类型(如指针),在作为参数传递给函数时都是通过值传递的方式。对于指针,虽然函数接收的是指针的副本,但由于这个副本指向原始数据的相同内存地址,所以函数内部对该地址的数据所做的修改会影响到原始数据。   可能得性能问题 最后一个问题,既然函数传递和容器类结构维护存取的都是副本,那么如果反复传递一些大对象,就会频繁复制对象,导致性能下降,所以传递对象时,应该尽量传递对象的指针,因为即使复制指针,指针类型长度也在可控范围内,如在 32 位机上占用 4 字节,在 64 位机上占用 8 字节。

2023/12/10
articleCard.readMore