Nicksxs's Blog

What hurts more, the pain of hard work or the pain of regret?

之前买来玩客云作为轻nas使用,但是由于只能外接硬盘,并且是usb2.0的接口,使用体验不是特别好,并且前期作为老母鸡还能兑换视频会员这种,后面取消了就不怎么使用了,前阵子玩客云停止提供服务了,所以就想可以折腾下,比如Armbian这样的,可以作为轻量服务器使用,功耗也比较低,跑一些简单的docker还是可以的,

配置是这样的,存储小了点,其他就是一个普通小服务器的配置了,usb2.0有点弱,但是现在有一些是正常退役,还有很多是PCDN被禁了宽带以后咸鱼出售的,带上电源跟网线才30左右,还是比较有性价比的
首先机器有几个版本,主要是1.0跟1.3,要注意1.3版本的主板上有个混淆点,会有一个1.2的版本,指的是具体存储或者网卡等组件的版本,玩客云版本是较大的 V1.3,而1.0则是比较小的字体,而且据网上的资料,1.0版本是有个MAC地址的贴条

可以看上图的标示,箭头指向的是短接点,这是1.0版本的,1.3版本是找不到这个短接点,而是在反面(有S805芯片和存储的一面)
新版手边没有,借用一张恩山的图

这个在最开始我刷第一个的时候比较困扰我,因为不确定版本,网上又有很多都是只截一小块区域的图,也不知道相对位置
首先如果是从闲鱼买来的已经刷成PCDN,某心云的需要通过短接刷底包之后再刷入系统,可以先用吹风机热风吹一会后盖,然后用一字螺丝刀从SD卡槽把外层的一片壳撬开,然后通过十字螺丝刀拧开之后就可以把主板拿出来
然后需要镊子或者其他铁丝,能够短接的就行,外加一个双公头USB线
刷机工具包可以用我这个分享的

1
2
https://www.alipan.com/s/sd4Wi6TDzeP
提取码: 84or

第一步打开USB Burning Tool, 要短接刷入update.img

通过文件导入网盘里的 update.img,然后先用双公头USB线连接电脑的USB口和玩客云靠近HDMI口的USB口,如果方便最好接到电脑后置的USB口,防止不稳定刷机终端,接下来就需要短接了,这里的姿势要先短接好,保持稳定,然后插上电源线,这里如果连接成功就会在软件左上会显示一栏连接成功,如果电脑连接音响也会有叮咚这样的声音,接下去就可以点开始进行刷入底包了,这里最大的困难点就是保持短接稳定后插电源,并且继续保持稳定刷入底包,底包刷入会比较快,等到刷机进度条走完刷成功后就点停止,然后关闭软件,拔掉玩客云电源,拔掉USB
第二步是刷入Armbian系统,根据一些教程有些说需要先刷入5.8,我这边是直接刷入了5.9的系统,也就多一步,准备一个U盘,16G或者8G都行,先用Etcher把5.8系统刷入U盘,刷入完成后再关闭软件,推出U盘,将U盘插入玩客云上靠近网口的USB口,然后再通电,指示灯会蓝色-> 绿色 -> 蓝紫闪烁 -> 蓝色,这样就是刷机成功了,会需要一点时间,刷机完成后再把5.9的的刷入U盘,再继续刚才的步骤,此时需要把网线插入玩客云,等待正常亮灯显示后,在路由器上可以找到一个aml-s812的设备,在通过ssh连接,账号是root,密码是1234,进入系统后通过以下命令就可以刷入emmc了

1
cd /boot/install  sudo ./install.sh

完成后会自动断开,拔掉U盘后再用同样方法连接就可以了,后续怎么使用就是Linux相关的了,后面也可以讲下docker的操作,但要注意架构,这个是arm架构32位的

原先在 23 年初的时候调研过一些国产的大模型,包括复旦开源的 MOSS 和清华的 ChatGLM,那时候还是早期版本,需要在 Linux 上,并且有比较好的显卡,而且一般来讲都得是 N 卡,过程中需要安装 pytorch和比较多依赖,并且当时的效果也还比较差,所以后面就没有长期使用。
最近看到谷歌在 2 月份开源了大模型 Gemma ,gemma 的博客在这里,想要在本地运行这个模型在现在这个阶段也变得简单很多,因为我们有了 ollama 工具

可以通过这个工具来运行大模型,并且已经支持了谷歌开源的 Gemma

我这边本地是 MacBook Pro 14 寸的,m3 pro 的处理器,18g 内存,刚好可以用 7b 量化的模型

这里有推荐的模型和内存推荐匹配规则,16g 可以运行 13B 及以下模型
下载安装完后我们可以用以下命令

1
ollama run gemma:7b

这里需要拉取模型,约5.2g 大小,考虑网络原因可能会比较慢

我们可以简单来试试问个问题


看出来回答的还是比较丰富的,谷歌出品还是比较有水平的,不至于像 ChatGLM 最初版本的在不做调优的情况下甚至有点前言不搭后语
对于想使用 chatgpt 但是没条件,这也算是个低配平替了, 并且已经是个比较可用的了,同时也方便进行学习调优等
如果想要类似于 chatgpt 那样的网页版,可以安装 open-webui
可以通过 webui 访问 ollama 运行的大模型,
用 docker 启动的命令也贴一下

1
docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main

不过有个小问题就是 docker 镜像拉取会有点慢,可以添加下国内镜像加速

1
2
3
4
5
6
7
{
"registry-mirrors": [
"https://dockerproxy.com",
"https://docker.mirrors.ustc.edu.cn",
"https://docker.nju.edu.cn"
]
}


这里有一个小区别,Gemma 在多轮会话的时候会在前面的答案基础上再完善。

补充一个在 windows 环境下,cpu 跑模型的也是可行的

现在是大模型可以深入千家万户了,大家都可以尝试下,如果对日常的工作学习有一些效率上的提升也是好的

线程池在实际使用过程中,有时候在理解比较偏理论的时候会出现一些判断错误,这里我们就来看一个实际的案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static final ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(1, 1,
0, TimeUnit.MINUTES,
new MyArrayBlockingQueue<>(2));
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
Thread.sleep(100);
for (int j = 0; j < 3; j++) {
threadPoolExecutor.execute(() -> {
int a = 0;
});
}
System.out.println("===============> 详情任务 - 任务处理完成");
}
System.out.println("都执行完成了");
}

MyArrayBlockingQueue 是我复制的 ArrayBlockingQueue 加了点日志,可以认为就是一样的,这种情况下
执行过程是怎么样的呢, 队列长度是 2,核心线程数和最大线程数都是 1,提交任务是采用了两层循环,内层是循环三次,往线程池里提交任务,然后内层循环完了以后会重新睡眠 100 毫秒
在进入下一次外层循环,如果能一眼看出来问题的说明对线程池了解得很深入了,如果没有的话我们就一起来看下
先说下结论,这个代码会出现拒绝异常

考虑下是什么原因呢,是不是我线程数太少了,放大一些,感觉符合直觉一点
修改成

1
2
3
4
private static final ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(100, 100,
0, TimeUnit.MINUTES,
new MyArrayBlockingQueue<>(2));

然而还是一样

只不过晚了点出现,那么问题出在哪呢
为什么我要去重写这个 MyArrayBlockingQueue,就是为了找到原因,其实很多讲解线程池的都是讲了线程池的参数,什么队列是链表的,数组的
但是没有讲到我是怎么往队列塞任务,怎么从队列取任务的呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lock();
try {
if (count == items.length) {
return false;
}
else {
enqueue(e);
return true;
}
} finally {
lock.unlock();
}
}

这里是往队列里塞任务,注意这里需要获得锁,
而对于获取任务呢

1
2
3
4
5
6
7
8
9
10
11
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}

注意这里也需要获得锁,当我一个线程池的线程数进入稳定状态,也就是保持一定数量的线程不变的情况下
上面是一种比较可能的情况,即核心线程数等于最大线程数,那么我在提交任务的时候是非常快的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}

再来看下这段代码,第一步只需要判断是否为空,第二步就是判断核心线程数量,明显我说的情况,前面两步就直接过去了
然后就是判断线程池运行状态和往队列里塞任务了,但是线程运行完一个任务主动从队列里获取则需要更多的逻辑
这样就造成了我往队列里塞任务会比获取任务快很多,队列一满,就会抛出拒绝异常
即使我把线程数量放大到 100 还是一样,只不过会出现的慢一点,那么口说无凭,我们来验证下,提交任务过快,那么我在提交
方法里做个延迟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lock();
try {
if (count == items.length) {
return false;
}
else {
enqueue(e);
return true;
}
} finally {
lock.unlock();
try {
Thread.sleep(1000);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}

这样就没啥问题了

除了最后这个加延时,其他的直接用 ArrayBlockingQueue 就可以实验,实操一下会对这个逻辑有更深的理解

https 和 tls

https 我原本的理解来自于几个概念

第一个是非对称加密

也就是公钥可以公开在网上,然后用公钥加密,由私有的私钥进行解密,然后就对 https 有了初步的偏颇的概念,就是浏览器用公钥进行加密,服务器端用私钥进行解密,这里再仔细想想会延伸出来几个问题,也是逐步完善我的后续理解

第二个就是非对称加密以后的对称加密

因为对加密的理解是超大的质数(这个还来源于大学的数据结构老师)相乘后会很难做质因数分解,比如最大的质数2^82,589,933 − 1,用十进制表示有24,862,048位,用现在计算机来算也是非常难的,那如果一直采用非对称加密,这个效率也会比较低,所以在网上查了一番之后知道了是先采用非对称加密,然后再用对称加密,因为非对称加密先有了安全保证,后续的消息就可以用对称加密的方式来进行传输安全了

第三个是最近一次看到的

也是一直缺乏思考的问题,因为 https 我们在使用的时候都会购买证书或者使用免费的证书,那这个证书和前面说的公私钥是什么关系,好像一直是悬空的,没想过这中间的相关性,证书究竟是怎么起作用的

下面是摘自 cloudflare 的 tls 握手流程,把证书的使用方式说的比较明白了

TLS 握手是由客户端和服务器交换的一系列数据报或消息。TLS 握手涉及多个步骤,因为客户端和服务器要交换完成握手和进行进一步对话所需的信息。

TLS 握手中的确切步骤将根据所使用的密钥交换算法的种类和双方支持的密码套件而有所不同。RSA 密钥交换算法虽然现在被认为不安全,但曾在 1.3 之前的 TLS 版本中使用。大致如下:

  1. “客户端问候(client hello)” 消息: 客户端通过向服务器发送“问候”消息来开始握手。该消息将包含客户端支持的 TLS 版本,支持的密码套件,以及称为一串称为“客户端随机数(client random)”的随机字节。
  2. “服务器问候(server hello)”消息: 作为对 client hello 消息的回复,服务器发送一条消息,内含服务器的 SSL 证书、服务器选择的密码套件,以及“服务器随机数(server random)”,即由服务器生成的另一串随机字节。
  3. 身份验证: 客户端使用颁发该证书的证书颁发机构验证服务器的 SSL 证书。此举确认服务器是其声称的身份,且客户端正在与该域的实际所有者进行交互。
  4. 预主密钥: 客户端再发送一串随机字节,即“预主密钥(premaster secret)”。预主密钥是使用公钥加密的,只能使用服务器的私钥解密。(客户端从服务器的 SSL 证书中获得公钥。)
  5. 私钥被使用:服务器对预主密钥进行解密。
  6. 生成会话密钥:客户端和服务器均使用客户端随机数、服务器随机数和预主密钥生成会话密钥。双方应得到相同的结果。
  7. 客户端就绪:客户端发送一条“已完成”消息,该消息用会话密钥加密。
  8. 服务器就绪:服务器发送一条“已完成”消息,该消息用会话密钥加密。
  9. 实现安全对称加密:已完成握手,并使用会话密钥继续进行通信。

为了学习这个过程,我们尝试用 Chrome 的自带抓包工具,以往这是可以通过在 Chrome 地址栏中输入 chrome://net-internals/#events 来打开,现在变成了两部分,chrome://net-export/ 这里可以开始抓包,然后会记录下一个抓包日志文件里,

然后再打开

https://netlog-viewer.appspot.com/#events 来查看具体的日志

这里我们以打开百度为例,即在打开 chrome://net-export/ 并启动抓包后,再在一个新 tab 打开 baidu.com,然后关闭

将日志文件在 https://netlog-viewer.appspot.com/#events 打开

这里可以看到

--> type 1 表示现在 tls 握手进行到哪一步了, 对应的值表示不同的阶段

代码(type)释义
0HelloRequest
1ClientHello
2ServerHello
11Certificate
12ServerKeyExchange
13CertificateRequest
14ServerHelloDone
15CertificateVerify
16ClientKeyExchange
20Finished

具体的对应关系是在这边

比如 11 之后表示我们从服务端收到了证书

然后就要去验证证书的可靠性

后面是验证后的结果

最后就是交换对称会话秘钥然后完成握手

这一篇主要补充两个内容,第一部分就是获取任务的逻辑
首先是状态判断,如果是停止了,SHUTDOWN或更大的了,就需要减小工作线程数量
并返回 null,使得工作线程 worker 退出,然后再判断线程数量和超时,同样如果超过了就会返回 null
然后就是去阻塞队列里获取任务,这里是阻塞着获取的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?

for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
// 状态异常,如果是大于等于 SHUTDOWN 的,则协助关闭线程池
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

// 如果线程数量超过核心线程数,则帮助减少线程
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}

// 如果前面的不符合,则从阻塞队列获取任务
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

阻塞队列的 poll 主要是通过锁,和notEmpty这个 condition 来等待制定的时间
指定时间后开始 dequeue 出队

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
long nanos = unit.toNanos(timeout);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0) {
if (nanos <= 0)
return null;
nanos = notEmpty.awaitNanos(nanos);
}
return dequeue();
} finally {
lock.unlock();
}
}

第二部分比较重要,有的同学的问题是为什么不一开始开到最大线程,而是达到核心线程数后就进队列了,
其实池化技术最简单的原因就是希望能够复用这些线程,因为创建销毁他们的成本太大了,如果直接最大线程数的话
其实都不用用到线程池技术了,直接有多少任务就开多少线程,用完就丢了,阐述下我认为比较重要的概念点

0%