方案 | 是否能播放原始文件 | 搜刮 | 硬件解码 |
---|---|---|---|
plex | 需付费直接放弃 | ||
emby | 大多数ok,因字幕等原因会触发转码 | 速度一般,无法手动搜索 | 需付费开启 |
jellyfin | 大多数ok,因字幕等原因会触发转码 | 速度一般,准确性一般,无法手动搜索 | 可开启 |
tmm + kodi | 可以 | 速度较快,可手动搜索,提升准确性 | 取决于电视 |
我的需求其实比较简单,观影设备只有客厅和卧室的电视,没有在移动端、web端看剧的需求。需要支持多端能同步观影记录。需要能尽可能播放原始文件,一方面是对画质的要求,另一方面是考虑家庭服务器的负载。
一开始尝试了 emby ,搜刮直接花了一个白天的时间,然后发现海报怎么都加载不出来,另外很多视频都会走转码(我就很奇怪为啥,我电视端明明支持这种格式,服务端仍然会转码),emby 的硬件解码需要收费,尝试了下破解也不太好用,遂放弃。然后尝试了 jellyfin,这个转码倒是比较完美的支持硬件解码了,然鹅搜刮还是难用,一是慢,二是不好进行微调。最后还是用回了 kodi。下面是 kodi 方案的教程。
rclone 的作用是把各种网盘挂载到 Linux 服务器上,这样访问网盘的文件就像是访问本地的文件夹一样方便。
安装的话直接按照官方的方法,一条命令完事:
1 | curl https://rclone.org/install.sh | sudo bash |
安装好了之后首先需要配置下,在终端执行 rclone config
命令后是一个交互式的命令行界面,能看懂英文的话照着提示一步一步来就行了,主要就是中间会给你一个url,在浏览器里用你的Google账号打开然后进行授权,得到一个授权码填到 rclone 里就行了,很简单就不赘述了,详细可以参考这个教程: https://ley.best/rclone/
挂载同样是一条命令
1 | rclone mount g: /home/gdrive-lower -copy-links --no-gzip-encoding --no-check-certificate --allow-other --allow-non-empty --umask 000 --daemon |
挂载好了之后,可以发现 /home/gdrive-lower 目录里已经是网盘上的文件了。不过都是只读的,我们搜刮到额外的信息海报等信息是无法存储到这个目录的,所以就需要 Linux 上另一个神器 OverlayFS 来解决这个问题。
OverlayFS 是 Linux 的一个文件系统,用途很广泛,比如 openwrt、docker 的文件系统就基于这个。简单讲一下他的作用。
OverlayFS 可以多个目录进行合并成一个新的目录,比如a、b两个目录里分别有 1.mp4 和 1.nfo 文件,那么他们合并成的新目录就同时包含了1.mp4和1.nfo两个文件。另外 OverlayFS 也有层级的概念,如果a、b目录有同名的文件,那么更高层级的目录的文件会优先被访问。
由于 OverlayFS 这么好用的特性,很适合用在我这个场景,能方便的把只读的目录变成可读写的。
首先创建两个目录:
1 | mkdir ~/gdrive-upper ~/gdrive-work /home/gdrive |
同样是一条命令
1 | sudo mount -t overlay overlay -o lowerdir=/home/gdrive-lower,upperdir=~/gdrive-upper,workdir=~/gdrive-work /home/gdrive |
下面samba 的 docker 有点问题,会进行 chown 修改文件所有者,这会导致文件被写入到upper上层目录,会使服务器硬盘爆炸。我最后使用的 ssh 协议(sftp)来访问,kodi上需要安装下 sftp 插件
既然已经搞定了读写的问题了,那么后续无论是要通过 smb 协议还是 ftp 协议访问都是配置一下的事了,我这里使用的是 docker 启动一个 smb 服务器:
1 | docker run -d \ |
TinyMediaManager 是一个很好用的媒体搜刮器,能很方便的搜挂电影、电视剧的片名、介绍、海报、缩略图等等,并且存储成 kodi、emby 能读取的 nfo 文件。
我们基本上只会用到左上角的两个按钮。
在设置里首先配置下要搜刮的目录,然后点击左上角的更新源按钮,他就开始自动搜索媒体文件了。
等搜索结束后,点击筛选,在里面选择新增季或新增电影,能筛选出刚刚新搜刮到的媒体文件,然后全选他们,点击搜索&剐削按钮旁边的下拉菜单,点击自动匹配,它会自动一个一个下载 nfo 和海报文件,最后匹配度低于 75% (在设置里可以调整)的视频,会提示给你来手动进行搜索匹配,速度挺快的,300 部电影十几分钟就搞好了。
另外搜刮有个小技巧,如果你的文件名也是和我一样是中文+英文混合命名的,并且包含什么 x264
web-dl
之类的标签,那搜挂起大概率会被这些词干扰而什么都搜刮不到。所以 tmm 在搜挂时支持从文件名中剔除掉一些字符,而且支持正则表达式。我直接把 [a-z]*
作为敏感词,可以提出掉所有的英文字符,只用中文来搜刮,这样准确率高很多。
配置如下图:
每次使用电脑可能会有所不便,毕竟个人电脑不能24小时开机,而服务器可以,其实 tmm 也支持docker:
1 | docker run \ |
启动后进入到容器里,vi /etc/cont-init.d/10-cjk-font.sh
编辑这个文件,把 http://dl-cdn.alpinelinux.org 替换为 http://mirrors.tuna.tsinghua.edu.cn/ 这样下载中文字体会快很多。然后重启容器。(如果你服务器在墙外,则不需要这一步,上面命令里的 --add-host
也可以删去)
启动后就可以通过浏览器打开 http://[容器ip]:5800/ 访问 tmm 了,这样开启搜刮任务后个人电脑就可以关机了,等有时间了再用浏览器看结果。事实上 docker 内部启动了个 xorg 桌面服务,然后通过 http 来访问 vnc,还是稍微有点卡顿的。
我有个特殊的需求是需要在客厅和卧室的不同电视里同步播放进度,kodi 默认是把媒体库存储到本地的 sqlite 里,但也支持存储到远端 mysql。mysql 服务器可以用docker启动,也可以用群晖 nas 里的 MariaDB 软件包。如果安装教程较多,不再赘述。
针对 kodi 需要写一个 advancedsettings.xml 配置文件:
1 | <advancedsettings> |
上面的 ip 地址用户名密码改成你自己的 mysql 地址。
把这个文件存储到每个电视的 Android/data/org.xbmc.kodi/files/.kodi/userdata/
下面,重启 kodi 后他搜挂到的信息就存储到 mysql 了。
(访问电视的文件,可以安装一个 ES文件浏览器,然后开启 ftp 访问)
另外推荐一个很好看的 kodi 皮肤 Arctic: Zephyr - Reloaded
,在系统自带的插件库里就能安装,个人感觉比 emby、jellyfin 的 android app 好用/好看多了 😂
当然这个方案也不是完美的,只是比较切合我个人的需求,比如搜刮不能自动进行,需要定期手工打开 TinyMediaManager 进行搜刮。另外对 iOS 移动端支持的不好,appstore 无法直接安装 kodi。
]]>1 | npm install hexo-cli -g |
这几步是在本机上安装好 hexo,并且生成一个 hexo 项目文件。
默认的主题不太喜欢,换了个叫 Hacker 的。
1 | git clone https://github.com/CodeDaraW/Hacker themes/Hacker |
这样就安装主题到 theme 目录了,当然也可以下载 zip,手动解压缩。
之后编辑 _config.yml
文件,修改成 theme: Hacker
。
另外,主题本身也有一些配置,放在 ./themes/Hacker/_config.yml
文件里。我的配置如下,根据自己不同需求做调整。
1 | menu: |
travis-ci 能帮你在每次 push 时自动部署。基本上按照 https://hexo.io/zh-cn/docs/github-pages.html 这个教程就可以了,但是有个坑是目前 GitHub pages 只支持用 master 分支了,所以写文章的分支就不能是 master 了,我这里用的是 build 分支。相应的,.travis.yml
文件也需要修改一下:
1 | sudo: false |
Github pages 支持自定义域名,需要将www的域名CNAME设定为 xxx.github.io
,另外需要把@域名(就是裸的没有任何子域名的,比如本站就是lengzzz.com)的A记录修改为下列IP:
1 | 185.199.108.153 |
都设定好之后,在项目里 source 下新增一个 CNAME
文件(全大写),里面写上域名就ok了。
之后在 GitHub 项目的 setting 里,也将 Custom domain 里配置上域名。之后 GitHub 会自动签发域名的 ssl 证书。最好也将 setting 里的 Enforce HTTPS 勾上,这样能强制使用 https。
大概等十几分钟后,使用域名 https://lengzzz.com/ 就能访问到了。
]]>[toc]
请全程使用网线连接进行操作,操作时千万不要断电,因为 R6220 使用 NAND flash,刷坏后无法使用普通编程器修复,刷机有风险,请扶稳方向盘谨慎驾驶
在浏览器打开 http://192.168.1.1/setup.cgi?todo=debug 这个网址,记着把 192.168.1.1 替换成你路由器的 ip 地址,成功后会显示 Debug Enabled !
说明已经成功。
使用 Mac 或 Linux 的用户,打开终端,执行 telnet 192.168.1.1
,可以登录到路由器的管理shell里,使用 windows 的用户,可以点这里下载 putty,然后如下图,Host Name填路由器ip,port写23,connection type 选 telnet:
点击 open 之后,会出现一个黑屏窗口,显示 R6220 login:
,输入 root
按回车,会显示一个大的 logo 和一个 #
号(如下),此时说明已经登录成功了。
1 | Welcome to |
此步骤可跳过,但有一定几率无法使用 Wi-Fi
准备一个格式化成 fat32 的U盘(最好重新格式化,卷标写成U),插入 R6220 的USB口中,然后在刚才的黑窗口里输入命令:ls /mnt/shares
会显示出你刚才优盘的卷标名,如果重新格式化成 U 了,那么就会显示一个 U。
下一步,执行 cd /mnt/shares/优盘的卷标名
,同理如果刚才显示 U 这里就应该执行 cd /mnt/shares/U
。
如果没有报错的话就进行下一步:
1 | dd if=/dev/mtd10 of=./mtd10.bin |
这是为了把 eeprom 备份到U盘中。如果执行之后,显示一个 #
说明成功了。现在把U盘拔下来插到电脑上,可以看到一个 mtd10.bin 文件,把它复制到电脑上备份。
从这里下载 pb-boot:
链接: https://pan.baidu.com/s/1jIONs6i 密码: nyn9
把这个文件放到U盘里,插入到路由器中。
使用 telnet 登陆到路由器,和第二步一样,执行 cd /mnt/shares/U
进入到U盘文件夹。执行下面的命令:
1 | mtd_write write pb-boot-r6220-20170801.img Bootloader |
成功后会显示:
1 | Unlocking Bootloader ... |
接下来路由器关机,然后用针扎着reset键开机,开机后发现电源灯和wan口灯呼吸闪烁,说明进入了 bootloader,在浏览器中输入 192.168.1.1 会看到 pb-boot 的界面:
现在说明不死 bootloader 已经刷好了,可以随意折腾路由器而不怕刷坏了,无论何时感觉刷坏了,都可以用针扎着reset键开机,来反复刷机重置系统。
小知识:bootloader 是在操作系统运行之前先运行的一个小程序,类似电脑的 bios,折腾路由器时就算把系统搞坏了,只要 bootloader 没被破坏,都可以刷回出厂设置的。
使用 mac 的用户再刷写了 pb-boot 之后,会发现无法获取到 IP 地址,可以这样操作:
在系统偏好设置中点网络,把网卡设置成下图所示:
打开终端反复执行如下命令:
1 | sudo arp -ad |
第一次可能会要求你输密码,输入即可
每执行一遍就再浏览器中刷新一次 192.168.1.1 ,直到浏览器能打开网页后停止执行命令。
一开始我是在 恩山无限论坛 下载的网友编译的固件,他的固件本身集成了好多功能,比如 广告屏蔽大师和s-s,不爱折腾的人刷他的固件用自带的功能就够用了,但是他的固件有个问题,固件的内核版本不是官方的版本,没办法安装官方软件仓库里的 kmod 软件包(可以强制安装,但是会大几率无限重启),另外固件的 opkg 好像也没有配置好,安装软件包会比较折腾。所以我建议还是先刷网友版,然后升级成官方版。
先从这里下载恩山网友版固件
链接: https://pan.baidu.com/s/1c1YSCLy 密码: 36vm
然后在 192.168.1.1 里,点击 browse 选择刚才下载的网友版固件,再点击 Firmware update,等 1 分钟就刷好了。
再次刷新 192.168.1.1 网页,发现已经进入 lede 的界面了。
用户名填 root,密码输admin,进入到管理界面中。
然后左侧点击网络、接口,把 WAN 口配置好,尝试一下能不能上去网。
对于不想折腾的朋友到此为止就可以了,恩山网友版的固件足够好用,喜欢折腾的朋友继续看下面(如果发现Wi-Fi无法正常使用的请看最下面)。
点击左侧的系统,然后点击备份/升级。现在咱们要升级成 lede 官方版。
或者从中科大的镜像里下载:http://mirrors.ustc.edu.cn/lede/snapshots/targets/ramips/mt7621/lede-ramips-mt7621-r6220-squashfs-sysupgrade.tar
在刷写新的固件那一栏,选择刚下载的固件,然后点击刷写固件。
等 1 分钟后,路由器自动重启,此时官方固件已经刷好了。
官方固件默认不开启Wi-Fi,默认不安装web管理界面,所以我们需要先使用命令行进行配置。
使用 ssh root@192.168.1.1
登陆,windows的朋友还使用 putty,但是 port 需填写 22,connection type 选 SSH。
然后输入 root 回车,再输入 admin 回车。
接着会看到一个 LEDE 的 logo,说明登陆成功了,下一步安装web管理界面。
以下内容选作
因为 lede 默认的软件仓库可能会比较慢,所以我们可以换成中科大的源,先执行vim:
1 | vim /etc/opkg/distfeeds.conf |
用 vim 打开 opkg 配置文件,然后多按几次键盘上的 dd
会发现之前的内容被删除了。之后按一下 键盘上的 I
,然后复制下面的内容,在终端里粘贴(putty用户直接在黑窗口里右键就可以了)
1 | src/gz reboot_core http://mirrors.ustc.edu.cn/lede/snapshots/targets/ramips/mt7621/packages |
然后按一下键盘上的 ESC 键,然后依次输入 冒号
、w
、q
、回车键
,退出到 shell 界面,现在中科大源就配置好了。
以上内容选作
接下来执行opkg来安装web管理界面,中文翻译和material皮肤:
1 | opkg update |
短暂等待后就安装好了,在浏览器打开 192.168.1.1 就能看到路由器管理界面了。
注意,此处有个小坑,如果在刚才升级到原版系统时,把 保留配置 的勾给去掉了,那么此时路由器是无法上网的,也就无法从软件仓库安装,所以如果你和我一样手贱去掉了那个勾,你现在可以把 R6220 接到一个已经配置好能上网的路由器的 LAN 口上,然后再进行 opkg install 操作。
官方版固件默认没有 upnp,需要ssh登陆到路由器执行下面命令安装:
1 | opkg update |
或者也可以在系统、软件包里安装 luci-app-upnp
然后在 服务、UPNP里勾选 Start UPnP and NAT-PMP service,点击保存&应用
广告屏蔽大师(英文名adbyby)是一个很好用的 lede 应用,能屏蔽爱奇艺、优酷等一系列广告而且不用等待。
1 | wget http://mirrors.ustc.edu.cn/lede/snapshots/targets/ramips/mt7621/lede-sdk-ramips-mt7621_gcc-5.4.0_musl.Linux-x86_64.tar.xz |
1 | git clone https://github.com/kuoruan/luci-app-adbyby.git |
1 | cd ~/lede-sdk-ramips-mt7621_gcc-5.4.0_musl.Linux-x86_64 |
1 | cd ~/luci-app-adbyby |
把 adbyby 放到 package 里,把 luci-app-adbyby 放到 feeds/luci/applications 里。
1 | cd ~/lede-sdk-ramips-mt7621_gcc-5.4.0_musl.Linux-x86_64 |
1 | make menuconfig |
会打开一个界面,在里面用上下键移动光标,用回车键选择,先选 LuCI,再选 Appications,然后找到 luci-app-adbyby 按两下空格使前面变成 [*]
,然后一直按 ESC 退出,退出前会询问是否保存,选 YES。
然后执行
1 | make V=99 |
最后会在 bin 文件夹编译出 luci-app-adbyby.ipk 文件,上传到路由器的/tmp 文件夹,执行 opkg install luci-app-adbyby.ipk
进行安装。
如果刷了 lede 之后找不到Wi-Fi,可以把 eeprom 还原成原厂。
mtd10.bin
放到路由器中在终端里执行:
1 | scp mtd10.bin root@10.0.0.1:/tmp |
使用 windows 的用户可以下载一个 WinScp 来远程复制文件:
File protocol 选择 scp,hostname输入路由器IP,port写22,username写root,password写密码。
点击login,然后再右侧窗口双击一下 ..
,然后再双击 tmp 文件夹。然后再左侧窗口找到刚才备份的 mtd10.bin 文件,直接拖到右侧窗口的空白区域。
使用 putty 或 ssh 登陆路由器,然后执行下面命令:
1 | mtd -r write /tmp/mtd10.bin factory |
成功后会自动重启。
然后在系统、备份/升级里重新上传sysupgrade.tar,并且不勾选保留设置。
]]>[toc]
用过 golang 的应该都知道,golang 程序基本上不会有各种依赖,都是光秃秃一个可执行程序,cp 到 /usr/local/bin
就算安装完成了,所以说安装 caddy 是很简单的,我给出三种方法。
1 | curl -s https://getcaddy.com | bash |
caddy 官方给出了一个安装脚本,执行上面的命令就可以一键安装 caddy,等执行结束后,使用 which caddy
,可以看到 caddy 已经被安装到了 /usr/local/bin/caddy
https://caddyserver.com/download 点这个链接进入到 caddy 官网的下载界面,网页左侧可以选择平台和插件,如果在 Linux 服务器上使用的话,platform 选择 Linux 64-bit 就可以了,plugins 如果暂时不需要的话,可以不选。然后点击下面的 DOWNLOAD 按钮,就下载到 caddy 了。同理,解压之后用 cp 命令放到 /usr/local/bin/caddy
就完成了安装。
1 | go get github.com/mholt/caddy/caddy |
对于安装了 golang 编译器的同学,只需要执行 go get 就能到 $GOPATH/bin 里,是否 cp 到 /usr/local/bin
里就看心情了。使用源码安装可以安装到最新版本的 caddy,功能上一般是最新的,而且因为是本地编译,性能可能会稍微高一些,但是可能会存在不稳定的现象。
Caddy 的配置文件叫做 Caddyfile
,Caddy 不强制你把配置文件放到哪个特定文件夹,默认情况下,把 Caddyfile 放到当前目录就可以跑起来了,如下:
1 | echo 'localhost:8888' >> Caddyfile |
在随便一个目录里执行上面代码,然后在浏览器里打开 http://localhost:8888 发现 caddy 已经启动了一个文件服务器。当临时需要一个 fileserver 的时候(比如共享文件),使用 caddy 会很方便。
当然了,在生产环境使用的时候就不能这么草率的把配置文件放到当前目录了,一般情况下会放到 /etc/caddy
里。
1 | sudo mkdir /etc/caddy |
除了配置文件,caddy 会自动生成 ssl 证书,需要一个文件夹放置 ssl 证书。
1 | sudo mkdir /etc/ssl/caddy |
因为 ssl 文件夹里会放置私钥,所以权限设置成 770 禁止其他用户访问。
最后,创建一下放置网站文件的目录,如果已经有了,就不需要创建了。
1 | sudo mkdir /var/www |
创建好这些文件和目录了之后,我们需要把 caddy 配置成一个服务,这样就可以开机自动运行,并且管理起来也方便。因为目前大多数发行版都使用 systemd 了,所以这里只讲一下如何配置 systemd,不过 caddy 也支持配置成原始的 sysvinit 服务,具体方法看这里。
1 | sudo curl -s https://raw.githubusercontent.com/mholt/caddy/master/dist/init/linux-systemd/caddy.service -o /etc/systemd/system/caddy.service # 从 github 下载 systemd 配置文件 |
基本的安装配置搞定之后,最重要的就是如何写 Caddyfile了。可以直接 vim /etc/caddy/Caddyfile
来修改 Caddyfile,也可以再自己电脑上改好然后 rsync 到服务器上。如果修改了 Caddyfile 发现没有生效,是需要执行一下 sudo systemctl restart caddy.service
来重启 caddy 的。
Caddfile的格式还是比较简单的,首先第一行必须是网站的地址,例如:
1 | localhost:8080 |
或
1 | lengzzz.com |
地址可以带一个端口号,那么 caddy 只会在这个端口上开启 http 服务,而不会开启 https,如果不写端口号的话,caddy 会默认绑定 80 和 443 端口,同时启动 http 和 https 服务。
地址后面可以再跟一大堆指令(directive)。Caddyfile 的基本格式就是这样,由一个网站地址和指令组成,是不是很简单。
指令的作用是为网站开启某些功能。指令的格式有三种,先说一下最简单的不带参数的指令比如:
1 | railgun.moe # 没错,moe后缀的域名也可以哦 |
第二行的 gzip 就是一个指令,它表示打开 gzip 压缩功能,这样网站在传输网页是可以降低流量。
第二种指令的格式是带简单参数的指令:
1 | railgun.moe |
第三行,log 指令会为网站开启 log 功能,log 指令后的参数告诉 caddy log 文件存放的位置。第四行的 tls 指令告诉 caddy 为网站开启 https 并自动申请证书,后面的 email 参数是告知 CA 申请人的邮箱。(caddy 会默认使用 let’s encrypt 申请证书并续约,很方便吧)
另外,简单参数也可能不只一个,比如 redir 指令:
1 | railgun.moe |
上面的 redir 指令带了三个参数,意思是把所有的请求使用 301 重定向到 https://lengzzz.com/archive/xxx,这个指令在给网站换域名的时候很有用。另外 tls 指令变了,不单单传 email一个参数, 而是分别传了证书和私钥的路径,这样的话 caddy 就不会去自动申请证书,而是使用路径给出的证书了。
在这个例子里还使用了 {uri}
这样的占位符(placeholder),详细的列表可以在这里查询到:https://caddyserver.com/docs/placeholders。
最后一种指令是带复杂参数的,这种指令包含可能很多参数,所以需要用一对花括号包起来,比如 header 指令:
1 | railgun.moe |
6-10 行的 header 指令代表为所有的 /api/xxx
的请求加上 Access-Control-Allow-Origin
和 Access-Control-Allow-Methods
这两个 header,从而能支持 javascript 跨域访问 ,第 9 行代表删除 Server header,防止别人看到服务器类型。
11-13 行使用了 fastcgi 指令,代表把请求通过 fastcgi 传给 php,ruby 等后端程序。
14-15 行,使用了 rewrite 指令,这个指令的作用是 服务器内部重定向 在下面的参数 to
后面,又跟了三个参数,这个功能上有点类似 nginx 的 try_files
。告诉 caddy 需要先查看网址根目录 /var/www 里有没有 {path} 对应的文件,如果没有再查看有没有 {path} 对应的目录,如果都没有,则转发给 index.php 入口文件。这个功能一般会用在 PHP 的 MVC 框架上使用。
随着一步步完善这个 Caddyfile,目前这个版本的 Caddyfaile 已经可以直接在网站中使用了。
刚才说的一直都是单个域名的网址,那么如果在同一个服务器上部署多个域名的网站呢?很简单,只需要在域名后面跟一个花括号扩起来就可以了,如下:
1 | railgun.moe { |
好了,基本的 caddy 配置就这些,详细的内容可以去官网上看文档学习。
[EOF]
]]>[toc]
在文章的开头需要先讲解一下c语言标准库中 setjmp 的内部实现,因为之后的 luaCoco 的实现就是对 setjmp 的数据结构的一个 hack。
先看看 setjmp 的用法
1 |
|
在调用 setjmp 的时候,会返回 0,从而会执行 if (code == 0) {
为 true 的 block,会打印出 before jmp
。当执行了 longjmp 之后,程序的执行会重新跳转到 setjmp 那一行(第七行)然而这次 setjmp 的返回值 code 不再是 0,而是 longjmp 的第二个参数(1024),这样就会打印出 jmp here, code: 1024
。
利用 setjmp 的这个功能能实现出很多有趣的东西,比如在c语言中做 Exception
,但是 setjmp 是怎么实现的呢?我们去读一读源码。
libc 的实现有好多种,常见的比如 glibc、uclibc 和 musl-libc,但是我们这次读一读 newlib 的源码。newlib 也是一个 libc 的实现,常用于嵌入式开发中。
打开 inlcude/machine/setjmp-dj.h 文件,可以看到 jmp_buf 的定义:
1 | // from inlcude/machine/setjmp-dj.h |
发现这个结构体是用来存储 cpu 的寄存器的。这里很好理解,因为要实现 长跳转(longjmp),必须要首先把跳转的目的地的现场先保存下来。
setjmp 函数做的工作就是保存现场:
1 | // from machine/i386/setjmp.S |
看图解释一下代码:
return addr
到 jmp_buf->eiplongjmp 的实现是正好相反的:
1 | SYM (longjmp): |
代码就不赘述了,基本上就是恢复现场、把longjmp的第二个参数作为返回值返回(7 - 9 行还有一个判断:如果参数为0的话,会把它改成 1)。
首先在看代码之前,先简单讲解一下我对 LuaCoco 的理解。lua 的协程其实也就是为每个协程维护了一个 context(所谓 context,既程序执行到某个地方时的状态,包括寄存器、callstack)。当协程之前相互 yield 的时候,切换一下 context。但是 lua 没做好的地方是 lua 仅保存了 lua 程序的 context,而 c 代码的 context 是没有保存的。这是因为 lua 很追求使用 pure c,不希望在源码中加入过多平台相关的东西。保存 lua 的 context 是比较简单的,因为所有 lua 程序相关的数据结构都放在 lua 虚拟机里,只需要每个 coroutine 保存一份就好了,而取得/保存 c 代码的上下文是需要操作平台相关的寄存器的。LuaCoco 就是为每个常见的平台都做了一份保存 c 程序 context 的实现。
下面看 LuaCoco 的源码。
LuaCoco 是对 lua 源码的一个 patch,coco 改动了 lua 的以下文件:
最主要的文件就是 lcoco.c 和 lcoco.h 了:
我看源码一般喜欢先看一下 header 文件,瞄一眼 coco 大体上做了什么事情。
1 | // from lcoco.h |
lcoco.h 里声明了 coco 定义的 4 个函数。很明显,lua_newcthread 是为了取代原生 lua 中的 lua_newthread 函数的。这一点可以在 lbaselib.c 文件的改动中看到。
1 | // from lbaselib.c |
这里用宏判断了是否开启 coco,如果开启的话就使用新的 lua_newcthread,否则的话还使用原生的 lua_newthread。
那么,下一步就着重来看看 lua_newcthread 的实现。
1 | /* Add a C stack to a coroutine. */ |
先看函数签名,发现比原生的 newthread 多了一个 cstacksize 参数,因为现在需要为 c 程序保存上下文,c 程序的执行需要一个 stack,所以每个 coroutine 都要有一个自己的 stack。这个 cstacksize 参数就是用来控制这个 stack 的大小的。
继续看代码,几个 if 的意思也很清晰,不表。发现重要的功能都封装到了 COCO_NEW
这个宏里面了。再一翻代码,这个宏又套了好几层宏和宏判断,搞得我很郁闷。所以我就使出了我的编译器大法!
1 | gcc -E -DCOCO_USE_SETJMP -D__linux__ -D_I386_JMP_BUF_H -D__i386 lcoco.c > _lcoco.c |
这个命令可以让编译器只执行预处理,说白了就是把 c 语言的宏全部展开。命令中定义的其他几个宏的意思分别是:使用 setjmp 实现协程、使用Linux架构、使用i386架构。
宏展开的结果如下:
1 | lua_State* lua_newcthread(lua_State* OL, int cstacksize) { |
可以看到,主要做的事情就是 alloc 了 coco_State 数据结构和 cstack。这里只申请了一次内存空间,然后通过指针操作分别把内存分成了 coco_State 和 cstack 两块。有点迷糊的可以看一下我画的图:
申请好空间之后,11到16行初始化了 coco_State 的几个字段。
17行把 coco 的指针放到了 NL 的前面(我并不知道为什么可以这么做,反正就是可以,看的时候我一脸“还有这种操作?”的黑人问号)
下面我们去看看 coco_State 的代码。
1 | struct coco_State { |
经过宏替换之后,coco_State 长上面这样。
lua_newcthread里的代码初始化了 ctx,allocptr,allocsize,arg0 字段。其中,对 ctx 的初始化操作比较让人在意,首先是调用 setjmp 初始化了 ctx,之后对 ctx 内部的几个字段进行了魔改。
1 | coco->ctx->__pc = (((coco_MainFunc)(coco_main))); |
有了之前 setjmp 的基础,这里就容易理解了,一旦对这个 ctx 调用 longjmp 的话,程序就会跳转到 coco_main
这个函数,并且把 stackptr 当作程序的 stack 来使用。这个 stack 的切换操作,其实就是程序 context 的切换
我们先不看 coco_main
做了什么,先看看这个 longjmp 会在什么时候调用,动脑子想一想,应该会是再执行 resume 的时候调用 longjmp,果然不出所料:
1 | int luaCOCO_resume(lua_State* L, int nargs) { |
在 resume 的时候,首先使用 setjmp 把主线程的状态保存到 coco->back
里,然后调转到 coco_main
。
下面看 coco_main
做了什么。
1 | static void coco_main(lua_State* L) { |
main 首先通过L取到了coco的指针(又一次黑人问号)。然后通过 luaD_rawrunprotected 调用了 lua 程序(也就是lua子线程)。之后判断子线程时候出错,设置错误码。最后,保存子线程的状态到 ctx,然后跳转会 back(调转到了luaCOCO_resume 第6行)。
继续回来看 luaCOCO_resume 函数,第 6 行后面宏展开前其实是这样:
1 | if (L->status != LUA_YIELD) { |
展开前的代码比较容易懂:如果子线程执行完了,就 free 掉,没啥可说的。
最后看看 yield:
1 | int luaCOCO_yield(lua_State* L) { |
跳转那里和之前讲的一样,多了的东西没太明白,好像是在复制 lua_State 内部的数据。
LuaCoco 内部实现的内容基本上就这些了。
题外话:其实除了魔改 setjmp,LuaCoco还有三种实现,直接内连汇编、使用ucontext和使用fiber,大家可以通过执行 gcc -E lcoco.c > _lcoco.c
加不同的宏定义来生成代码自行研究(其实原理都差不多)。
这部分就简单讲一下带过了,毕竟不是那么通用的内容。
经常做移植的同学应该大多知道,移植的时候最重要的是需要了解这个 architecture 的 ABI(application binary interface)。Google 一番之后发现了这个pdf,不过先不急着看 ABI,先看一下这个 CPU 的寄存器吧。
xtensa 有 16 个 32 位的通用寄存器,名字分别叫 A0 ~ A15,一个 PC 程序计数器,再加上一个 SAR 寄存器(不知道干嘛用的,不过好像移植的话用不上),还算挺简单的设计,没有奇奇怪怪的东西。
接下来,再看看 xtensa 的 ABI,在 PDF 的 chapter 8 里找到了 关于 Xtensa ABI 的部分。xtensa 使用了两种ABI,一种叫 Windowed Register
另一种叫 CALL0
。NodeMCU 只会用到 CALL0 所以我们简单讲一下 CALL0。
先看表格:
| Register | Use |
| | |
| a0 | Return Address |
| a1 (sp) | Stack Pointer (callee-saved) |
| a2 – a7 | Function Arguments |
| a8 | Static Chain (see Section 8.1.8) |
| a12 – a15 | Callee-saved |
| a15 | Stack-Frame Pointer (optional) |
A0 保存了函数的返回地址,A1保存了栈指针,a2到a7一共6个寄存器用于传递函数的参数,更多的参数会在栈中传递,a12-a15需要被调用者自己保存。
看完了寄存器的使用,看一下 xtensa 的栈帧(stack frame)格式:
可以看出来,xtensa 的指针也是向低地址方向增长的。在 SP 的上面会保存依次 6个参数以外的参数
、局部变量
等
搞清楚 ABI 了,最后的工作就是 coding 了。
遇到的第一个问题是,因为我们要对 setjmp 进行 hack,而不是使用汇编,所以我们仅能操作内存,而不能操作寄存器,所以如何将 lua_State 传递给 coco_main 呢?最常见的方法就是使用 哑参数(dummy args)。
1 |
我们把 coco_main 的参数列表定义成这样,前面a到f的参数都是用不上的,这样就不用管 a2 到 a7 这几个寄存器了。所以我们只需要把L指针放到SP指的位置就好了。
1 | #define COCO_PATCHCTX(coco, buf, func, stack, a0) \ |
stack[0] = (size_t)(a0);
这一句中的 a0 就是 lua_State 的指针,把他放到了 SP[0] 这里。
第二个问题就是对 jmp_buf
进行 hack 了,我们需要搞明白 jmp_buf
里怎么放东西的,这需要看这个平台编译器的源码了。https://github.com/pfalcon/esp-open-sdk 这里是编译器的源代码,他是使用 crosstool 的,会一边下载源代码,一遍编译,下载好的源代码放在 crosstool-NG/.build/src/newlib-2.0.0 ,正好我们看看 newlib 中 setjmp 的实现:
1 | #else /* CALL0 ABI */ |
上面的 else 宏代表这段代码是使用 CALL0 ABI 才会被编译。看一下代码,可以了解到以下对应关系:
| jmp_buf | Register | Use |
| | |
| jup_buf[0] | a0 | Return Address |
| jmp_buf[1] | a1 | Stack Pointer (callee-saved) |
| jmp_buf[2] - jmp_buf[5] | a12 - a15 | Callee-saved |
所以这段代码也就不难理解了:
1 | #define COCO_PATCHCTX(coco, buf, func, stack, a0) \ |
分别是把 buf[0]
和 buf[1]
改成 coco_main 和 我们为协程新申请的栈。
其他代码基本上是 copy coco 的,详细的可以看我 github:
https://github.com/zwh8800/nodemcu-firmware/commit/1f31aa32901f07b6414c0471eb19f7bdd44d93a6
[EOF] 基本上这次移植涉及到的内容就这些,完。
]]>点这个按钮哦
]]>Surveillance Station 是群晖上的一个功能套件,可以管理网络摄像机,功能十分强大,而且原生支持很多品牌的网络摄像机,但是不支持小蚁摄像机。不过还好的是 Surveillance Station 支持 rtsp 协议,只要能在小蚁上开启 rtsp 服务就可以了。
这次没有自己编译 rtsp 服务,一是因为没有找到一个好用又轻量的,二是因为刚好找到一个俄罗斯的国际友人做的 “小蚁Hack”项目 里面正好有我想要的 rtsp 服务,我就直接拿来用了。
我们只需要用到他项目里的一个文件,叫做 rtspsvrM 可以在 https://raw.githubusercontent.com/fritz-smh/yi-hack/master/sd/test/rtspsvrM 下载到。如果小蚁的系统版本比较老,可能需要 rtspsvrK 或者 rtspsvrI。我用的最新的 1.8.6.1 所以使用 M 版本的。具体的规则可以在 https://github.com/fritz-smh/yi-hack/blob/master/sd/test/equip_test.sh#L216 这个脚本里找到,我就不赘述了。
下载好 rtspsvrM 文件后,放到 sd 卡根目录,然后再创建一个服务。
1 |
|
然后重启,执行下 netstat -tuanp
,可以看到 rtspsvrM 已经监听 554 端口了。
1 | # netstat -tuanp |
说明服务已经起来了。另外这个 rtspsvrM 虽然没有开源,但是他好像没有建立什么乱七八糟的网络连接,姑且认为它不会泄漏用户信息。
现在回到 nas 的管理界面中,打开 Surveillance Station,点击网络摄像机点新增。
之后把 IP、端口号填写上,品牌选择最上面的用户自定义。最后一个视频原路径很关键,需要填写成 rtsp://10.0.0.224:554/ch0_0.h264
把其中的 IP 地址替换成你摄像机的 IP 就可以了。
之后,就可以好好享受 Surveillance Station 的强大功能了。
]]>据说某些系统版本的小蚁摄像头默认没关闭 telnet 服务,那么在做以下事情之前可以先试试你的摄像头是不是已经开启了 telnet 。在终端里运行 telnet xxx.xxx.xxx.xxx
xxx.xxx.xxx.xxx 是你摄像头的 ip 地址,可以在路由器管理界面中查到。如果出现 (none) login:
字样,说明你已经拿到了 shell,就不用做下面的操作了,直接跳到这里。
equip_test.sh
粘贴以下内容。1 |
|
根据网上的说法,equip_test.sh
会在开机的时候自动运行。
这个脚本的内容很简单,第一步创建 /etc/init.d/S88telnet
这个文件,内容如下:
1 |
|
这个文件相当于创建了一个 busybox-init 的 服务
[^ref3],和 ubuntu、CentOS 的服务类似,不过功能更简单一些,直接就是一个 shell 脚本。这个 shell 脚本开启了 telnetd 后台程序。
第二步是把自身重命名并且重启,避免每次摄像头开机重复运行。
现在,可以把内存卡查到摄像头中开机,不出意外的话现在再次在终端里输入 telnet xxx.xxx.xxx.xxx
就可以看到 (none) login:
了,现在输入用户名 root
按回车,再输入密码 1234qwer
就可以进入小蚁摄像头的 shell 界面了。
拿到 shell 之后先进去看了看系统信息,没想到这个小蚁摄像头的配置这么寒酸。。。? cpu 是 N 年前的 arm9 ,内存只有 32MiB ,你没看错,就是 32MiB,一点都不夸张,root 文件系统只有 3.5MiB 的空间,毛都放不了,只能想办法把东西放内存卡上。
1 | $ uname -a |
soc 用的好像是屁眼公司的 hi3518,小米在手机行业和华为干架那么厉害,芯片还是得用菊花厂的啊。
回归正题,我们开始交叉编译 rsync。一开始我用的是 ubuntu 上自带的交叉工具链 gcc-arm-linux-gnueabi
折腾半天编译出来的东西没法用,这才发现这个摄像头用的 libc 是 uClibc, 这种低端错误都犯了,之前自己做嵌入式的时候最常用的就是 uClibc 都忘记了?。
后来想办法下载一个 arm-linux-uclibc-gcc
ubuntu 上好像没有现成的源可以下,Google 一下发现了这个 maillist http://lists.busybox.net/pipermail/buildroot/2010-January/031634.html。
这个帖子讨论的是一个叫 buildroot 的东西。我研究了一下这个项目,它竟然可以一键 build 出整个嵌入式系统,包括 host 上运行的交叉工具链、bootloader、kernel、root filesystem甚至是各种软件包,其中就包括 rsync。那我还费心找什么工具链啊,直接用它就好了。
现在要做的就是找一台 Linux 机器,在上面运行以下命令,编译出 rsync。
1 | wget https://buildroot.org/downloads/buildroot-2016.11.1.tar.gz |
执行 make menuconfig
之后会出现一个菜单(和编译内核的菜单用的同一个)。
Target options
再进入 Target Architecture
菜单,选择 ARM (little endian)
Target Architecture Variant
选择 arm926t
其他选项不用动,按两下 esc 退出来Toolchain
菜单,C library
选择 uClibc-ng
Kernel Headers
,貌似没有 3.0.8
选个最低的 Linux 3.2.x kernel headers
吧Target packages
,在 Networking applications
里找到 rsync
按空格键打上勾Shell and utilities
把 inotify-tools
打上勾,这个我们也要用到Save
,回车存盘退出。之后执行 make,经过漫长的等待,终于编译好了。进入到 buildroot-2016.11.1/output/target 文件夹可以看到整个根目录,在 /usr/bin
可以看到编译好的 rsync 。不过只把这个文件放到摄像头是不行的,因为还有 rsync 的动态链接库 so 文件也得放进去。
我直接 tar zxcf target.tgz ./target
把根目录打包,放到摄像头内。然后摄像头开机进入 shell,执行 tar xvf target.tgz
解包到内存卡里。然饿。。。现在也不能运行,因为必须把链接库的目录设置好,在 shell 里再执行一下
1 | export PATH=$PATH:/tmp/hd1/target/bin:/tmp/hd1/target/usr/bin |
这个是把动态库的搜索路径设置好,具体可以看我这篇文章,现在,执行 rsync,竟然报错 libz.so.1 not found
。我进入发现只有 libz.so.1.2.8
并没有 libz.so.1
原来是因为内存卡是 fat32,不支持软连接,只好复制一份了?。cp libz.so.1.2.8 libz.so.1
这也是没有办法的事。
最后,执行一下 rsync,可以看到久违的帮助信息了,真不容易。
rsync 只能同步一次文件,当文件保持同步之后就会退出,怎么样想个方法能让两端文件实时同步呢?答案是利用 inotify-tool。这个工具利用了内核的通知系统,当文件进行改动之后,就会发出一个通知,此时再调用 rsync 进行同步就可以了。
所以把它写成了一个脚本[^ref4]:
1 |
|
解释一下这个脚本。前两句不用说了,第三局是导出一个系统变量,rsync 会读取这个变量拿到 rsync 的密码[^ref2]。之后是调用 inotifywait,当他发现文件的修改、删除、创建、修改属性时,会输出到标准输出中。
标准输出被重定向到管道中,read files
当接不到数据时会 block,当接收到数据之后会向下执行 rsync。
rsync 的参数 -vzrtopg
里的v是verbose,z是压缩,r是recursive,topg都是保持文件原有属性如属主、时间的参数,–delete参数会把原有getfile目录下的文件删除以保持客户端和服务器端文件系统完全一致^ref1。
然后,在 /etc/init.d
创建一个服务:
1 | cat /etc/init.d/S90nasync |
重启摄像头,你会发现,nas 里有自动同步过来的视频了。
[^ref2]: rsync without prompt for password
[^ref3]: Create and control start up scripts in BusyBox
[^ref4]: Linux-rsync+inotify 文件实时同步
]]>shairport 是一个音频 AirPlay receiver 服务器。但是不幸的是 shairport 的作者两年前停止更新了,就有了另一个开发者 fork 了 shairport 做出了 shairport-sync。
shairport-sync 基于 shairport,在此基础上还改进了音视频的同步的问题,这样使用 shairport-sync 播放视频时不会出现影音不同步的问题了。
ubuntu 16.04 的软件仓库里已经集成了 shairport-sync,这样只需要执行 apt install
就可以安装了。
但是 shairport 还需要 avahi-daemon 这个服务,avahi-daemon 是开源的,它实现了苹果的 mDNS 协议(在苹果的设备上对应的服务是 Banjour)。shairport 需要在 avahi 上注册自己。
1 | sudo apt install avahi-daemon |
shairport-sync 的配置非常简单,它的配置文件放在 /etc/shairport-sync.conf
,打开它之后会发现里面有很多配置项,我们只需要简单的配置下 name 就可以了,其他的选项不用动。
1 | // General Settings |
改完配置之后记得重启一下服务:
1 | sudo systemctl restart shairport-sync.service |
安装之后有可能会不出声音,这是因为 shairport 的用户不在 audio 组了,这样的话 shairport 没有音频设备的权限,执行下面语句可以解决。
1 | sudo usermod -aG audio shairport-sync |
1 | # 首先确保自己在 master 且代码是最新的 |
1 | # 首先把 master 上的代码更新一下 |
1 | # 把修改暂存起来 |
1 | # 只提交这几个文件 |
打开 sourcetree
选中像提交的行,点按钮暂存行
然后:
1 | # 只提交某几行 |
已暂存的修改,叫做
stashed changes
1 | git reset HEAD 1.go |
已经被 add 的修改,叫做
staged changes
;未 add 的叫做unstaged changes
1 | git checkout -- 1.go |
1 | # 回到上一个 commit,把这个 commit 的修改变成 unstaged changes |
1 | # 同上,但是最后再push的时候需要加 -f |
1 | # 创建一个和上个提交完全相反的提交 |
在 golang 中,通过几个基本的 interface 对流操作进行了抽象。
首先是最基本的Reader、Writer,定义了对于一个流来说最基本的操作:读、写。这两个 interface 定义在 io
包里。
1 | type Reader interface { |
更进一步的,最常见的流就是文件了。对于文件来说,除了简单的读写操作之外,还有 Seek、ReadAt、WriteAt、Close 操作。标准库对这些操作也进行了抽象。
1 | type Seeker interface { |
有了这些基础设施之后,就可以使用 golang 的组合大法了:
1 | type ReadCloser interface { |
其他还有一些不很常用的操作。
写到一个Writer中、从一个Reader中读取。这两个操作会自动判断EOF,如果没有把所有数据写完/读完,就会继续写/读。
1 | type WriterTo interface { |
还有一些面向 byte
和 rune
的读写操作:
1 | type ByteReader interface { |
Scanner
允许把一个读出的字节重新放回流中。这个操作有点类似 Peek 但是比 Peek 别扭一些。这种操作在做词法分析器的时候很有用。
下面是一些这些 interface 的实现。
使用 os.Open
、os.OpenFile
可以打开一个文件进行读写。它返回一个 *os.File
结构体,这个结构体实现了上面除了杂项外的接口。
使用 os.Pipe
可以创建一个操作系统提供的管道(参见 unix 管道)。这个函数也是返回一个 *os.File
结构体。
net.Conn
是个 interface,他也实现了 io.Reader
、io.Writer
、io.Closer
这三个接口。
有时候我们需要把一段内存当作流来处理,我们把这种设施叫做内存流。内存流在某些情况下非常有用。
在 strings
包中,strings.Reader
实现了 io.Reader
、 io.Seeker
、io.ReaderAt
、io.WriterTo
、io.ByteScanner
、io.RuneScanner
这些接口。
可以将一个字符串当作一个只读流来使用。
bytes
包中提供了一个比 strings.Reader
更高级的内存流-- bytes.Buffer
。它支持读写操作,同时还可以讲写入的数据转换成字符串来使用。这个结构体一般会被当做 golang 中的 StringBuilder 使用。
另外,如果需要将 []byte 转换为只读流,可以使用 bytes.Reader
它和 strings.Reader
类似。当数据只需要进行读操作时,使用这两个 Reader 会比 Buffer 要高效一些。
这些内存流都是非阻塞的,如果内存中没有数据了,会立即返回一个 EOF 错误。
有时我们需要一个可以阻塞的内存流。当 buffer 中无数据的时候,Read 操作会被阻塞住;当 buffer 满时,Write 操作也会阻塞。
io.Pipe
提供了这个功能。
1 | func Pipe() (*PipeReader, *PipeWriter) |
使用 io.Pipe
函数创建一对 pipe,对 PipeReader 进行读操作,对
PipeWriter 进行写操作。
io.LimitReader
函数可以限制一个 Reader 的读取字节数
io.TeeReader
可以在你读一个 Reader 的同时,将数据写入到一个 Writer 中:
1 | func teeExample(input io.Reader) { |
这个例子可以将 input 的内容同时写到 console 和 xxx.log 文件中。
io.MultiReader
、io.MultiWriter
函数可以将多个 Reader 或 Writer 合并成一个 Reader 或 Writer。
程序的代码还是比较简单的,大概类似下面:
1 | // 两个参数分别为线程数量和总任务数 |
为了确定线程的数量,我逐步增加线程数记录系统各项数值。下面是在一台 RMBP 上做的测试。
| T | QPS | CPU | DBCPU |
| | | | | |
| 1 | 9000 | 37 | 81 |
| 2 | 14000 | 60 | 150 |
| 3 | 15500 | 70 | 195 |
| 4 | 16500 | 90 | 260 |
可以发现随着线程数增加,性能会逐步提高,但是当线程数提高到一定程度之后,性能不增反降(这部分数据没放)。如果继续增加线程数进行测试,会发现系统的某些性能指标会被耗尽,程序开始报错。
1 | recovering from panic: dial tcp 127.0.0.1:3306: getsockopt: operation timed out |
为什么会出现这种情况呢?在讨论这些问题之前,先回顾一下 goroutine 的设计与实现。
事实上,在 golang 中的 goroutine 已经不是传统意义上的线程了。传统的操作系统提供的线程在使用时有一些限制,超过一定数量之后会对性能造成影响。而 golang 提供的 goroutine 是一种 green thread。
Green thread 和 NodeJS 的异步回调方案有类似的地方,都在底层调用的系统的异步 IO 系统调用。
由此可见,Green thread 在语言设计上比异步回调方案要略胜一筹,不会出现“冲击波”的现象(具体在 NodeJS 中如何避免“冲击波”代码可以看我这篇文章await & async 深度剖析),但在性能上实际是差不多的,都避免了大量使用操作系统级的线程带来的性能问题,同时又能充分的利用 CPU。
但是,今天我想谈的问题并不是 CPU 利用率/线程数量的问题,这个问题已经被上述两种设计方案比较完美的解决了。
在实际中,更多遇到的是 cpu /线程数量之外的资源瓶颈。比如锁、数据库链接、tcp链接。举个例子,假如做个爬虫应用,每个 goroutine 爬一个网页,golang 虽然号称百万级别的 goroutine ,但是你每个 goroutine 里面创建一个 tcp 链接,不到 5 万个 goroutine 就会把系统的 tcp 资源耗尽。
回到文章最上面的情况,在 goroutine 里调用了 dao.SyncSingleUser
函数,这个是一个数据库操作,不可避免的会有 socket 操作、硬盘操作。如果将所有数据库操作都无脑的放到 goroutine 中执行,当资源出现瓶颈之后,大量 goroutine 会阻塞或报错。
因此,我在项目里使用了一个 goroutine 池,来确保不会过多使用系统资源导致崩溃。那么问题来了,池里究竟该放多少线程?
又回到了最初使用系统线程时遇到的问题,不过这次导致问题的不再是线程资源,而是其他资源瓶颈(如 tcp、数据库链接 等)。这些资源比线程资源更加复杂,更加难以把控。更加难做 benchmark,也就更难找出一个通用的方法来解决实际场景的问题。
思考过后,我在网上开始搜索解决方案。发现这篇文章的作者和我做了类似的思考:《并发之痛 Thread,Goroutine,Actor》。作者最后得出 Actor 模型能解决一些问题(不过我才疏学浅至今不怎么理解 Actor 模型),但并发带来的问题还远远没到解决的程度。
革命尚未成功 同志任需努力
所以说,软件工程没有银弹,路漫漫其修远兮,吾将上下而求索。
]]>趁着这个机会我总结了一下常见的 GC 算法。分别是:引用计数法、Mark-Sweep法、三色标记法、分代收集法。
原理是在每个对象内部维护一个整数值,叫做这个对象的引用计数,当对象被引用时引用计数加一,当对象不被引用时引用计数减一。当引用计数为 0 时,自动销毁对象。
目前引用计数法主要用在 c++ 标准库的 std::shared_ptr 、微软的 COM 、Objective-C 和 PHP 中。
但是引用计数法有个缺陷就是不能解决循环引用的问题。循环引用是指对象 A 和对象 B 互相持有对方的引用。这样两个对象的引用计数都不是 0 ,因此永远不能被收集。
另外的缺陷是,每次对象的赋值都要将引用计数加一,增加了消耗。
这个算法分为两步,标记和清除。
如图所示。
但是这个算法也有一个缺陷,就是人们常常说的 STW 问题(Stop The World)。因为算法在标记时必须暂停整个程序,否则其他线程的代码可能会改变对象状态,从而可能把不应该回收的对象当做垃圾收集掉。
当程序中的对象逐渐增多时,递归遍历整个对象树会消耗很多的时间,在大型程序中这个时间可能会是毫秒级别的。让所有的用户等待几百毫秒的 GC 时间这是不能容忍的。
golang 1.5以前使用的这个算法。
三色标记法是传统 Mark-Sweep 的一个改进,它是一个并发的 GC 算法。
原理如下,
过程如上图所示。
这个算法可以实现 “on-the-fly”,也就是在程序执行的同时进行收集,并不需要暂停整个程序。
但是也会有一个缺陷,可能程序中的垃圾产生的速度会大于垃圾收集的速度,这样会导致程序中的垃圾越来越多无法被收集掉。
使用这种算法的是 Go 1.5、Go 1.6。
分代收集也是传统 Mark-Sweep 的一个改进。这个算法是基于一个经验:绝大多数对象的生命周期都很短。所以按照对象的生命周期长短来进行分代。
一般 GC 都会分三代,在 java 中称之为新生代(Young Generation)、年老代(Tenured Generation)和永久代(Permanent Generation);在 .NET 中称之为第 0 代、第 1 代和第2代。
原理如下:
因为 0 代中的对象十分少,所以每次收集时遍历都会非常快(比 1 代收集快几个数量级)。只有内存消耗过于大的时候才会触发较慢的 1 代和 2 代收集。
因此,分代收集是目前比较好的垃圾回收方式。使用的语言(平台)有 jvm、.NET 。
go 语言在 1.3 以前,使用的是比较蠢的传统 Mark-Sweep 算法。
1.3 版本进行了一下改进,把 Sweep 改为了并行操作。
1.5 版本进行了较大改进,使用了三色标记算法。go 1.5 在源码中的解释是“非分代的、非移动的、并发的、三色的标记清除垃圾收集器”
go 除了标准的三色收集以外,还有一个辅助回收功能,防止垃圾产生过快手机不过来的情况。这部分代码在 runtime.gcAssistAlloc
中。
但是 golang 并没有分代收集,所以对于巨量的小对象还是很苦手的,会导致整个 mark 过程十分长,在某些极端情况下,甚至会导致 GC 线程占据 50% 以上的 CPU。
因此,当程序由于高并发等原因造成大量小对象的gc问题时,最好可以使用 sync.Pool
等对象池技术,避免大量小对象加大 GC 压力。
保证网站高性能的前提是做性能测试,如果连网站的性能指标都不知道,怎么判断一个网站“慢不慢”和“高性能”呢。
对一个网站做性能测试时,主要需要测试一下几项数据:
在做性能测试时,一般以并发数为自变量,逐步提高并发数,每次记录上述四个指标,制作出一张表格以方便后面进行分析。
例子如下:
并发数 | 响应时间 | TPS | 出错率 | CPU | 内存 | 备注 |
---|---|---|---|---|---|---|
100 | 10ms | 200 | 0% | 5% | 200MiB | 性能测试 |
200 | 15ms | 266 | 0% | 10% | 300MiB | 性能测试 |
300 | 20ms | 300 | 2% | 15% | 400MiB | 性能测试 |
400 | 50ms | 330 | 20% | 35% | 600MiB | 负载测试 |
500 | 100ms | 350 | 25% | 40% | 800MiB | 负载测试 |
600 | 500ms | 360 | 30% | 50% | 1.0GiB | 压力测试 |
700 | 1200ms | 350 | 50% | 60% | 1.5GiB | 压力测试 |
800 | timeout | 0 | 100% | N/A | N/A | 压力测试 |
看表格不是很直观,我简单解释一下。这个测试大致分为三个stage:性能测试、负载测试、压力测试。
分别以 “资源消耗” 为横轴、TPS 为纵轴画出函数图像可以更直观的观察两者之间的关系。
分别以 “并发数” 为横轴、“响应时间” 为纵轴,观察两者关系。
通过以上分析,判断一个网站是否高性能,可以通过并发数来做基本的判断。更准确的应当通过上图中的b点处(系统最佳运行点)的并发数量来判断网站性能。
这部分和我们讨论的网站架构关系不大,对于后端开发者来说需要注意的点有:使用CDN、注意开启浏览器缓存、合并JS减少HTTP请求等等,主要还是靠前端工程师的努力啦。
对于应用层的性能优化主要有三种方法:加缓存、使用异步操作、应用集群化。这三者越靠前者越应当优先使用,效果越好,对系统影响越小。
有一位计算机科学家曾说“缓存是最伟大的发明”,在系统中使用缓存可以挡住大部分请求从而可以不访问数据库。但是并不是说缓存可以在任何地方使用(滥用)。使用缓存有以下原则。
具体在使用缓存时可以使用业界通用的开源缓存系统,如redis、memcached、jboss cache等。
网站一些操作可以不直接访问数据库,而是将任务放入消息队列异步化。是用另一台 consumer 服务器从消息队列中取出任务进行数据库访问。
这样可以有效的起到“削峰”作用。经常用于应对突然增加的并发数,比如在抢购系统中,在前几分钟会接到很多请求,将这些请求放入消息队列中逐个处理,当处理结束后再使用 websocket 等通知方式告知用户是否抢购到商品。
使用负载均衡技术,在网站入口搭建一台负载均衡服务器(如nginx),入口后搭建一个应用服务器集群,当接收到请求后,负载均衡服务器使用一定算法将请求平均的打到不同的应用服务器上。
这样,比如应用集群有10台机器,那么理想状态下每台机器的负载只有总负载的 1/10 。
先略过不表
可用性指网站的故障情况。网站的故障时间越短,故障范围越小说明网站可用性越高。对于网站提高网站的可用性,需要从四方面入手:负载均衡(入口)、应用、缓存、数据库。
需要在 nginx 挂了之后还有机器能顶上。可使用的技术有 VRRP,具体说来就是当一台机器挂了之后,另一台机器检测到了之后立即把自己的IP地址设置为原有机器的IP地址。通过这种抢地址的方式来接管新的请求。
应用层做到高可用的方式就是“集群化”。部署多台应用服务器,对于负载均衡来说每台业务服务器都是一样的,当一台机器挂了之后将请求打到其他机器上就好了。
做应用集群化的核心就是“业务层不要有状态”。将状态保存到缓存层和数据库中。以下几点是大家常犯的错误:
现在普遍使用缓存,因为缓存可以替 mysql 挡住大部分请求。所以这种情况下,整个系统都过于依赖缓存层。因为一旦缓存不可用之后,所有的请求都打到数据库上,导致数据库压力过大。
因此,保证缓存层高可用也是必要的了。达到缓存层高可用的方式也是集群化,将缓存分的细一些,不同的数据存储到不同的 cache 中。这样某一个 cache 宕机之后不至于太过严重。
数据层有一个经典的原理,叫做 CAP 原理。CAP 分别指:一致性( C onsistency)、可用性(A vailibility)、分区耐受性(P atition Tolerance)。
CAP 原理认为,一个数据服务通常无法同时做到以上三点。所以一般情况下网站会舍弃一致性,而达到可用性和分区耐受性(既可伸缩性)。
数据层做高可用的主要技术是:数据备份。
数据备份主要分为:热备、冷备。
冷备指定期对数据库进行备份,如果发现数据库出错了会滚到上一个备份。这种方式其实做不到数据最终一致。
热备主要分为同步热备和异步热备。
这两种功能 mysql 都有实现。
可伸缩性指当业务达到负载上限时,可通过简单的增加机器的方式提高系统的负载能力。
可以通过 DNS 来实现,目前 DNS 服务器会使用轮转算法返回 IP 地址,这样就可以把请求分配到不同的负载均衡上。
其实应用层做到可伸缩性和高可用的解决方案一致,都是集群化,核心都是保证应用无状态。
缓存要保证水平扩展的同时不增加额外的消耗需要一些技术。常见的技术是一致性 HASH。可以保证水平扩展后较少的数据失效。
数据库层做可伸缩的话常用的技术就是分库、分表。
书中也介绍了一些开源的技术如 Cobar 之类的,不过我没有去深入研究。
网上找了张图,总结的不错。
]]>简单请求是指:
当浏览器遇到这种请求时,会直接向服务器发送请求,但是在把结果返回给JS代码前会做一次检查。浏览器会查看 response header 中是否有 Access-Control-Allow-Origin
这个 header 。如果有且允许当前 Origin 访问的话,才会真正将结果返回给 JS 程序。
如果一个 Ajax 请求不是上面所说的那种。比如使用 POST 方法并且 Content-Type 是 application/json。那么浏览器则不会直接发送这个 HTTP 请求,而是先发送一个预请求。
预请求使用 OPTION 方法发送,并且带上 Access-Control-Request-Method
、Access-Control-Request-Headers
等 header。
举例说明:
在 a.com 下使用 POST 请求 b.com/user ,数据类型是 application/json ,并且使用了私有 header :x-token
1 | OPTIONS /user HTTP/1.1 |
这个 OPTION 请求类似一个询问,他会询问服务器是否支持用户的请求,服务器应当返回他所支持的请求。当进行了 OPTION 请求之后,浏览器进行判断此次请求是否合法,如果合法的话才会发起真正的 HTTP 请求。
默认情况下跨域请求是不带 Cookies 的,如果需要 Cookies 的话,在客户端需要将 XMLHttpRequest 对象的 withCredentials 设置为 true 。同时,服务器也应当做相应调整。
1 | var xhr = new XMLHttpRequest(); |
服务器应当返回 Access-Control-Allow-Credentials: true
,否则浏览器不会将结果返回给 JS 程序。
总的来说,为了实现安全的跨域 Ajax 请求。会对 ajax 做一下检查。对于简单的请求,浏览器会直接发送请求,然后对结果进行检查;对于复杂请求,会首先发送一个预请求进行检查,检查通过之后才发送真正的请求。
为了实现检查的目的,在 HTTP 协议中新增了如下几个请求头和响应头。
apt
命令,用起来很符合我的审美,不过这个东西好像也存在比较久的一段时间了,实在是后知后觉了。apt 这个命令行工具在功能上基本涵盖了以前 apt-get 和 apt-cache 的功能,在他们之上提供了一个 high-level 的命令行界面,而且也更有交互性。
在命令行下敲击 apt 后会打印出一些常见命令:
1 | zzz@ubuntu-server ~ $ apt |
虽然这个 超级牛力
是什么鬼我也不明白了。。。(莫非是 powered by GNU?)但是基本的使用介绍还是很清晰的。对于咱们普通用户来说,最明显的就是把 search 功能合并过来了。
另外,比较好用的一点是 list 命令。可以使用 apt list --upgradable
来查看需要升级的软件包,有点类似 brew outdated
。
还有一个 apt show
可以打印出软件包的基本信息。
1 | zzz@ubuntu-server ~ $ apt show zsh |
总之就是各种命令清晰漂亮了很多,个人用起来很舒服。
]]>elasticsearch 是一个 java 编写的搜索和分析引擎,功能十分强大。但是并不意味着你的程序必须使用 java 开发,elasticsearch 是一个独立运行的程序,它会开放一个 RESTful 的接口供人调用,所以使用起来十分方便,甚至使用 curl 就能对它进行访问。另外,elasticsearch 的可伸缩性也很吸引我,使用 elasticsearch 组建一个集群十分方便,只需要把几个 elasticsearch 放到同一个局域网内就可以了,不用做任何配置你就能跑起来一个集群。这样,当你的数据量或者并发量增大的时候,只需要简单的购买几台新服务器就能解决性能问题。
我是通过 Elasticsearch 权威指南(中文版) 这本书来学习的,也推荐大家看一看,比我讲的好。
elasticsearch 中有几个基本概念,大概可以和数据库的这几个概念对应起来(如下表)。但是有一点需要注意,elasticsearch 中不会限制数据必须存在一个二维表中,你可以保存一个对象,一个数组,一个字符串,或者一个整数,就像一个 JSON 一样,十分灵活。事实上,elasticsearch 的通讯协议确实是使用 JSON 的。
| 数据库 | elasticsearch |
| | |
| Databases | 索引(Indices) |
| Tables | 类型(Types) |
| Rows | 文档(Documents) |
| Columns | 字段(Fields) |
| schema | Mapping |
在 elasticsearch 中保存的每条记录叫一个 document
,它可以是一个包含很多字段的对象,默认情况下每个字段都能被搜索。
使用 curl 就可以对 elasticsearch 进行操作,但是我还是推荐一个 chrome 应用 postman
,有 JSON 语法高亮和检测,还可以保存历史记录。
在 elasticsearch 中存储数据的行为叫做 索引(index)
。使用 HTTP 协议的 PUT 动词可以存储数据。
1 | PUT http://localhost:9200/mdblog/note/23432 |
如上,把要存储的数据写成一个 JSON 对象,放到 HTTP 的 Body 中传送给 elasticsearch 即可存储数据。
我们可以看到 url 中包含了 4 部分的信息。
| 名字 | 信息 |
| | |
| localhost:9200 | Elasticsearch 的 url |
| mdblog | 索引名(Index) |
| note | 类型名(Type) |
| 23432 | 文档ID(Document ID) |
很方便吧。
大家应当已经想到了,使用 GET 动词。
1 | GET http://localhost:9200/mdblog/note/23432 |
返回的信息会多一些 metadata 。
1 | { |
搜索的话可以使用 查询 DSL
进行,说是 DSL(领域特定语言) 听起来很吓人,实际上就是几个 JSON 对象的组合而已。
调用搜索接口需要在 url 后面加一个 _search
。
1 | GET http://localhost:9200/mdblog/note/_search |
这样,就可以使用 match 查询进行查询了。
结果:
1 | { |
另外,我们可以为搜索加上高亮:
1 | GET http://localhost:9200/mdblog/note/_search |
这样的话,在 hit 中会有一个 highlight
字段,所有关键字会用 <b></b>
扩起来。
默认情况下 elasticsearch 是不需要“建表”操作的。mapping(类似数据库的表结构)会在第一次 index 的时候建立。但是提前建立 mapping 有助于查询。建立 mapping 也是使用 PUT 动词。
1 | PUT http://localhost:9200/mdblog/note/_mapping |
如上,建立一个 mapping 。主要是设置一下数据类型和查询方式。
在 golang 中有方便的 package 来操纵 elasticsearch。我使用的是 gopkg.in/olivere/elastic.v3
还不错的一个包,所有操作都是链式调用,很有 linq 的感觉。
使用 elastic 需要先创建一个客户端:
1 | func InitElasticSearch() (err error) { |
然后,就可以用 client 进行操作了。
1 | noteDetail := model.NoteDetail{ |
索引一条记录
1 | func IsNoteDocumentExist(uniqueId int64) (bool, error) { |
判断是否存在
1 | func SearchNoteByKeyword(keyword string, |
搜索记录
总的来说,elasticsearch 还是很方便强大的,好评。
]]>地址在这里:https://lengzzz.com/download/golang/
包含了 golang 1.5 之后的所有版本,所有平台的安装包和源码包都放在里面,自行 control + f
搜一下吧。新版本的 golang release 之后,应该在一两天内可以拉取过来。
欢迎使用。
]]>trap
这个 builtin command。trap 命令类似 c 语言中的 signal
函数,可以注册一个函数,当程序收到信号时执行函数。但是 trap 命令也有一些比较坑的小细节,比如 trap 的执行时机。在 c 语言中,程序收到信号之后会立即执行 signal 注册的信号处理函数。那么在 shell 程序中呢,信号究竟什么时候被 trap 处理?是像 c 程序一样停下程序立即执行,还是等待当前程序执行之后再执行?
1 |
|
我们先写一段程序做个实验。在 bash 中执行它,然后按 control + c
发现程序立即停止了,然后打印了 signal received,似乎 trap 是类似 c 程序一样立即处理信号的。但其实是被表面现象蒙骗了。
我们再次执行这个 shell 程序,然后打开另一个 shell,在里面执行 kill -SIGINT xxx
发现 shell 程序并没有退出,等待了 100 秒之后才打印 signal received 退出。
因为第一次在键盘上按 control + c
后,sleep 程序和 shell 程序同属一个进程组,所以也接到了 int 信号退出了,而第二次只有 shell 程序收到信号,所以造成了两者的差异。我第一次也被蒙骗了,傻乎乎的以为 trap 能在 sleep 时也处理信号。
那么问题来了,我们如果需要在 sleep 时处理信号,并且及时退出怎么办呢?国外一篇文章给了例子^sample,我就负责搬运一下了。
1 | pid= |
利用了 bash 的 builtin 命令 wait。wait 是一个 shell 内部的命令,而不是一个外部程序,所以它没有前面的限制。另外,要记得退出时 kill 掉 sleep 进程,擦好屁股。
]]>客户端是使用 golang 开发的,放到了 github 上 https://github.com/zwh8800/cloudxns-ddns。需要的可以自己编译,不过我已经做好了 docker 镜像 https://hub.docker.com/r/zwh8800/cloudxns-ddns 了可以直接使用。
首先,拉取镜像:
1 | docker pull zwh8800/cloudxns-ddns |
然后,编写一个很简单的配置文件,放到某个文件夹中(如/home/zzz/cloudxns-ddns/config,下面以此为例子)
1 | [CloudXNS] |
上面 APIKey
是你在 CloudXNS https://www.cloudxns.net/AccountManage/apimanage.html 申请的 key,填进去即可。下面是你想要动态的域名,可以写很多。
然后,启动镜像即可。
1 | docker run --name cloudxns-ddns -d -v /home/zzz/cloudxns-ddns/log:/app/log -v /home/zzz/cloudxns-ddns/config:/app/config zwh8800/cloudxns-ddns |
注意一点,需要把刚写的配置文件当作 volumn
挂载到容器上,如上 -v /home/zzz/cloudxns-ddns/config:/app/config
。这样的话,你可以方便的修改配置文件然后 docker restart cloud-ddns
。