2024年11月


0 abstract

In Reinforcement Learning (RL), designing precise reward functions remains to be a challenge, particularly when aligning with human intent. Preference-based RL (PbRL) was introduced to address this problem by learning reward models from human feedback. However, existing PbRL methods have limitations as they often overlook the second-order preference that indicates the relative strength of preference. In this paper, we propose Listwise Reward Estimation (LiRE), a novel approach for offline PbRL that leverages secondorder preference information by constructing a Ranked List of Trajectories (RLT), which can be efficiently built by using the same ternary feedback type as traditional methods. To validate the effectiveness of LiRE, we propose a new offline PbRL dataset that objectively reflects the effect of the estimated rewards. Our extensive experiments on the dataset demonstrate the superiority of LiRE, i.e., outperforming state-of-the-art baselines even with modest feedback budgets and enjoying robustness with respect to the number of feedbacks and feedback noise. Our code is available at
https://github.com/chwoong/LiRE

  • background & gap:
    • 在强化学习 (RL) 中,设计精确的、与人类意图保持一致的奖励函数,具有挑战性。Preference-based RL(PbRL)从人类反馈中学习奖励模型,可以解决这个问题。
    • 然而,现有的 PbRL 方法存在局限性:它们只能应对“A 比 B 好”“B 比 A 好”这种 0 1 的情况,而忽略了表示偏好相对强度的二阶(second-order)偏好。
  • method:
    • 在本文中,我们提出了 Listwise Reward Estimation (LiRE),一种新颖的 offline PbRL 方法,它通过构建轨迹排名列表(Ranked List of Trajectories,RLT)来利用二阶偏好信息。
    • 构建 RLT:使用与传统 PbRL 相同的三元组 feedback
      \((\sigma_0,\sigma_1,p)\)
      。对于新给出的 segment,用插入排序的方式将其放到目前的 RLT 里。
  • experiment:
    • 这篇文章提出了一个新的 offline PbRL dataset,用于评价 reward model 的学习效果。因为 d4rl 环境太简单,还会有 survival instinct(生存本能)现象,
      不适用于 reward 学习
    • 实验证明,LiRE 在反馈预算适中的情况下 outperform baselines,并且在 feedback 数量和 noisy feedback 方面更加稳健。
  • offline PbRL:
    • Reward Learning from Human Preferences and Demonstrations in Atari.
      arxiv
      这篇是 2018 年的文章,先对 expert demo 做模仿学习,然后 rollout 得到一些 segment,拿这些 segment 去打 preference,最后 PbRL 微调。
    • Sequential Preference Ranking for Efficient Reinforcement Learning from Human Feedback.
      open review
      这篇是 SeqRank,是 2023 neurips 的文章。SeqRank 把新得到的 segment 和先前收集的 segment(最近收集的 / 先前所有 segment 里最好的)拿去比较。如果能比出
      \(σ(t_0)<σ(t_1)<σ(t_2)<σ(t_3)\)
      的结果,就能得到 3×2×1 = 6 = (n-1)! 个 preference,但是我们其实只比了三次;SeqRank 就通过这种思想来对 preference 做数据增强。
    • lire 讲 offline PbRL 的思路是,最近的工作专注于直接优化策略、省掉 reward model(比如 DPO),但是选择 informative query 也很重要。OPRL 是一种 active query selection 方法,选择 disagreement 最大的 query,但它没有使用二阶偏好。
  • Second-order Preference Feedback:
    • 有些方法直接获得一个相对 preference 数值(明显更好 / 略好),或每个轨迹的绝对 rating(非常好 好 一般 中 差),但它们获取 feedback 的成本较高。
      • Llama 2: Open Foundation and Fine-Tuned Chat Models.
      • Weak Human Preference Supervision For Deep Reinforcement Learning. TNNLS(Transactions on Neural Networks and Learning Systems)2021 年的文章。
        arxiv
        首先让 p 从 {0, 1} 变成 [0, 1],直接优化交叉熵损失,做了一些神秘归一化,然后搞了一个 preference predictor 做数据增强;没有仔细看。
      • Rating-based Reinforcement Learning. AAAI 2024,
        arxiv
        。这篇文章的名字是 RbRL;人类直接给一个 segment 一个 {0, ..., n-2, n-1} 的 rating,然后我们学一个 rating model,试图判断一个 segment 被分到了哪个 rating。这个 rating model 通过判断 Σr hat(σ) 和定义的 n 个 rating 类别的奖励值边界的大小关系,来判断 segment 被分到了哪个 rating。rating 类别的奖励值边界会动态更新。
    • 有很多 Learning-to-Rank 的工作,它们已经拿到了二阶偏好,试图去学一个 ranking 的评分。
    • 还有一些工作,它们可以从多个轨迹的全序列表(比如 A<B<C<D)得到二阶偏好,但是真去构建一个大列表太慢了,应该更高效地构建(?)
    • 然后又提了提 SeqRank。

4 method

  • 首先对 preference 做了一些假设:
    • 完备性:假设拿到
      \(\sigma_0 ~ \sigma_1\)
      ,要不是
      \(\sigma_0 \succ \sigma_1,~ \sigma_0 \prec\sigma_1\)
      ,要不就认为一样好
      \(\sigma_0 = \sigma_1\)
      ,认为没有比不出来的情况。
    • 传递性:假设如果有
      \(\sigma_0 \succ \sigma_1,~\sigma_1 \succ \sigma_2\)
      ,那么有
      \(\sigma_0 \succ \sigma_2\)

4.1 构建 RLT(Ranked List of Trajectories)

  • 我们的目标是得到形式为
    \(L=[g_1\prec g_2\prec ⋯\prec g_s]\)
    的 RLT,其中
    \(g_i=\{σ_{i_{1}},\cdots,σ_{i_{k}}\}\)
    是一组具有相同优先级的 segment。(有点像 帕累托前沿 分层 之类)
  • 具体构建方法:我们每拿到一个新 segment,就把它拿去跟目前的 RLT 插入排序比较,找到一个放新 segment 的位置。直到 feedback 预算用完。
  • 表 1 计算了 LiRE 的 feedback efficiency 和 sample diversity,并且与 vanilla 方法、SeqRank 进行了比较。feedback efficiency 定义为 [我们获得的 feedback 数量] / [我们进行比较的数量] 。sample diversity 定义为 [我们获得的 feedback 数量] / [所用到的 segment 数量] 。
  • 我们并没有构建一个很长的 RLT,而是去构建多个相对短的 RLT,为每个 RLT 都设置 feedback 预算。

4.2 从 RLT 里学 reward model

从 RLT 里推导出
\((\sigma_0, \sigma_1, p)\)
的 preference 数据,其中
\(p\in\{0,0.5,1\}\)

然后优化 PbRL 的 cross-entropy loss:(我也不知道包含 p = 0.5 的 cross-entropy loss 是不是这么写)

\[\begin{aligned}
L= & -E_{(σ_0,σ_1,p)\sim D}\bigg[ p(0)\log P_\psi[σ_0\succ σ_1] + p(1)\log P_\psi[σ_0\prec σ_1]
\\
& \quad\quad\quad\quad\quad\quad\quad +~p(0.5) \log P_\psi[σ_0= σ_1] \bigg ]
\\
P_\psi&[σ_0\succ σ_1] = \frac{\exp\sum_t \hat r_{\psi}(s_t^0,a_t^0)}{\sum_{i\in\{0,1\}}\exp\sum_t \hat r_{\psi}(s_t^i,a_t^i)}
\end{aligned}
\tag 1
\]

不知道为什么,LiRE 把 reward model 建模成了线性形式(而非指数形式):

\[P_\psi[σ_0\succ σ_1] = \frac{\sum_t \hat r_{\psi}(s_t^0,a_t^0)}{\sum_{i\in\{0,1\}}\sum_t \hat r_{\psi}(s_t^i,a_t^i)}
\tag 2
\]

LiRE 声称这样可以放大 learned reward model 的奖励值的差异,拉高比较好的 (s,a) 的奖励。这个线性 reward model 的最后一层 activator 也是 tanh,为确保概率(公式 2)是正的,reward model 的输出是 1 + tanh() 。

也可以使用 listwise loss,在 Appendix A.3,有点复杂 先不看了()

5 experiment

5.1 settings

  • LiRE 的自定义 dataset:


    • d4rl 存在问题,即使使用错误的 reward,训练出来结果也很好,因此
      不适用于 reward 学习
    • 因此,LiRE 对 metaworld 和 dmcontrol 收集了新的 medium-replay 数据集,并使用了 IPL 的一部分 medium-expert 数据集,细节见 Appendix C.2。
      • medium-replay:用三个 seed 训 ground truth reward 下的 SAC,当 success rate 达到大概 50% 的时候,就停止训练,把 replay buffer 作为 offline dataset。
      • 然后,对每个数据集,他们验证了使用 0 reward、负 reward 训出来策略的性能不佳,因此适合评测 reward learning。
    • 先前工作也有一些自定义数据集,但它们在这个数据集上的实验只使用了 100 个或更少的 feedback,而 LiRE 要使用 500 1000 这个数量级的 feedback。
  • baselines:


    • 马尔可夫奖励(MR)、
      Preference Transformer
      (PT),
      OPRL
      ,Inverse Preference Learning(IPL)、Direct Preference-based Policy Optimization(DPPO)、SeqRank。
    • MR 是 PT 的 baseline 之一。PT 的主要贡献是把 reward model 换成 transformer,但是故事很合理。OPRL 的主要贡献是用类似 pebble 的方法选 disagreement 最大的 query。IPL 和 DPPO 没有 reward model,直接优化 policy。
  • LiRE 的实现细节:


    • 对于 LiRE,我们使用线性 reward model,并设置为每个 RLT 的 feedback 预算 Q 为 100:如果反馈的总数为 500,则将构造 5 个 RLT。所有的 offline RL 部分都使用
      IQL
      。Appendix C.4 有超参数之类的具体细节(表 18)。
    • preference 的 segment length = 25。因为 metaworld 的 ground truth reward 在 [0, 10] 之间,因此,LiRE 标记 segment reward 之和差异小于 12.5 的 query 为 p=0.5。

5.2 实验结果

  • 实验主要在 LiRE 自己收集的 MetaWorld medium-replay 上做。Meta-World medium-expert 在 Appendix A。
  • LiRE 声称 PT 跟 MR 差不多;OPRL 因为最小化了(?)reward model 之间的 disagreement,所以性能会有提升;IPL 和 DPPO 基本比不上 MR;但 LiRE 结果很好。

5.3 & 5.4 ablation

  • LiRE 声称自己的性能提升主要因为 1. 线性 reward model,2. RLT。
    • 表 3 显示,线性 reward model 可以有效提高性能(到底是为什么呢……)RLT 也能提高性能。
    • 图 2 可视化了 reward model 预测的奖励和 ground truth 奖励的关系,发现无论是 MR 还是 LiRE,线性 reward model 都能得到更与 ground truth reward 线性相关的 reward 预测值,因此认为是更好的(怎么会有这么神奇的事情呢)。
    • LiRE 推测,使用线性 reward model 和 RLT 可以更清楚地区分估计奖励的最佳和次优部分,还是在讲二阶偏好的故事。
    • Appendix A.5 有线性 reward model 的更多实验。表 12 显示,MR 和 OPRL 换上线性 reward model 后性能都有提升,PT DPPO 性能则有所下降。图 7 声称 online PbRL 中线性 reward model 也可以表现更好。
  • 图 3 做了不同 feedback 数量的 ablation。表 4 做了不同 Q(单个 RTL feedback 预算数量)的 ablation。
  • 图 4 做了 noisy feedback,随机 filp preference 结果。表 5 6 比了 SeqRank。
  • 图 5 改了给 p=0.5 的 reward 阈值。
  • 图 6 把 LiRE 跟 OPRL 和 PT 相结合,发现性能有升有降。
    • OPRL 性能下降是因为,基于 disagreement 的 query selection 可能会对相似的 segment pair 过度采样,这些片段可能很难区分。
    • PT 的 motivation 是捕获奖励建模中的时间依赖关系,因此它似乎难以从 RLT 中准确捕获二阶偏好信息,可能因为对过去 segment 的过拟合。

5.5 human 实验

  • 表 7 在 button-press-topdown 用了作者打的 200 个 feedback,发现 LiRE 比 MR 和 SeqRank 好。

6 & 7 conclusion

  • LiRE 的 limitations:
    • 一个 RLT 可能无法并行化地构建。
    • LiRE 的 RLT 依赖于对 preference 的完备性 + 传递性假设。



7AqXs9.png

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!

本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!

前言

某多多的验证码,类型有很多,滑块、点选、手势等等,其中点选的题目繁多,每刷一次都能给你整个新的出来。本文主要对验证码的相关加密算法(不同类型的都大差不差)进行逆向分析,识别模型的训练后续也会推出相关文章,上述内容都仅供学习交流:

7AqMXZ.png

逆向目标

  • 目标:某多多点选验证码逆向分析

  • 网站:感兴趣的小伙伴私聊

抓包分析

触发验证码,抓包,
/api/phantom/vc_pre_ck_b
接口返回的 salt 值,会参与到加密参数的生成:

7AuBjG.png

/api/phantom/obtain_captcha
接口获取验证码图片以及题目内容,都经过了加密处理:

7AuLtb.png

  • pictures:验证码背景图片链接;
  • semantics:验证码需点击的题目内容。

该接口的请求参数中
anti_content

captcha_collect
都经过了加密,
verify_auth_token
是触发验证码之后,返回的识别标志:

7AulyP.png

/api/phantom/user_verify
验证接口:

7Au0ee.png

  • 验证成功:{'code': 0, 'leftover': 9, 'result': True};
  • 验证失败:{'code': 3002, 'leftover': 9, 'result': False};
  • 验证时间过长:{'code': 1001, 'leftover': null, 'result': False}。

请求参数与
/api/phantom/obtain_captcha
接口基本相同,多了一个
verify_code
,也就是点击的坐标:

7Au7Uw.png

anti_content
参数的解决思路,网上有很多详解文章,跟栈就行了,本文就不对此多加分析了:

7Aw00H.png

逆向分析

验证码图片

一般的验证码,其获取图片的接口,基本都是直接返回的下载链接,或者经过 Base64 编码后的值。本案例中,验证码的背景图片链接、标题都经过了加密处理,需要进行逆向分析。

这些图片内容,在后端进行加密,那必然会在前端解密出真实的链接,然后渲染到页面上,据此,基本就有两种方案。

第一种,跟栈分析,
/api/phantom/obtain_captcha
接口调用的堆栈基本都在
_app.js
文件中,是异步的,关于异步跟栈的分析流程,之前的文章写过很多,就不再赘述了。跟栈进去,在下图 return 处下个条件断点
i.pictures
,返回验证图片相关信息时即会断住:

7AuX9b.png

单步调试,分析,寻找解密点。向上跟栈到此处,可以看到,c 就是经过加密后的标题,经过
this.formatSemantics
方法处理后,还原出了明文标题:

7AuVnH.png

跟到
this.formatSemantics
中去,
kc.Base64.decode(Oa.a.decode(e[0]))
方法解密出了明文值,最终的结果替换掉特殊字符 @ 即可:

7AucpZ.png

kc.Base64.decode
就是 base64 解码(
https://www.kgtools.cn/secret/base64):

7AuvGU.png

跟进到
Oa.a.decode
中,将相关算法扣下来即可:

7Auazq.png

Python 复现:

# ======================
# -*-coding: Utf-8 -*-
# ======================
import re
import base64

from loguru import logger

c = [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
     -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 24, 3, -1, 20, -1, 17, 8, -1, 30,
     -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 12, 22, 10, -1, -1, 15, 14, 6, -1, 5, -1, -1, 7, 18, -1, 25, 9, -1,
     28, -1, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1, 21, -1, 31, 13, 16, -1, 26, -1, 27, -1, 0, 19, -1, 11, 4, -1,
     -1, 23, -1, 29, -1, -1, -1, -1, -1, -1]

# 正则表达式, 用于匹配多字节的 UTF-8 字符
b = re.compile(r'[\xC0-\xDF][\x80-\xBF]|[\xE0-\xEF][\x80-\xBF]{2}|[\xF0-\xF7][\x80-\xBF]{3}')


# UTF-8 解码
def y(e):
    length = len(e)
    if length == 4:
        t = ((7 & ord(e[0])) << 18 | (63 & ord(e[1])) << 12 | (63 & ord(e[2])) << 6 | 63 & ord(e[3])) - 65536
        return chr(55296 + (t >> 10)) + chr(56320 + (t & 1023))
    elif length == 3:
        return chr((15 & ord(e[0])) << 12 | (63 & ord(e[1])) << 6 | 63 & ord(e[2]))
    else:
        return chr((31 & ord(e[0])) << 6 | 63 & ord(e[1]))


def decode_captcha_img(e):
    t = len(e)
    if t % 8 != 0:
        return None
    n = []
    for r in range(0, t, 8):
        o = c[ord(e[r])]
        i = c[ord(e[r + 1])]
        a = c[ord(e[r + 2])]
        p = c[ord(e[r + 3])]
        h = c[ord(e[r + 4])]
        m = c[ord(e[r + 5])]
        v = c[ord(e[r + 6])]
        g = (31 & o) << 3 | (31 & i) >> 2
        b = (3 & i) << 6 | (31 & a) << 1 | (31 & p) >> 4
        y = (15 & p) << 4 | (31 & h) >> 1
        w = (1 & h) << 7 | (31 & m) << 2 | (31 & v) >> 3
        x = (7 & v) << 5 | 31 & c[ord(e[r + 7])]

        n.append(chr((31 & g) << 3 | b >> 5))
        n.append(chr((31 & b) << 3 | y >> 5))
        n.append(chr((31 & y) << 3 | w >> 5))
        n.append(chr((31 & w) << 3 | x >> 5))
        n.append(chr((31 & x) << 3 | g >> 5))

    _ = ''.join(n)
    _ = _.replace('#', '').replace('@?', '').replace('*&%', '').replace('<$|>', '')
    return _


# 解码标题
def decode_captcha_title(pic_encode, decode_type=""):
    decoded_img = decode_captcha_img(pic_encode)
    if decoded_img is None:
        return None
    if decode_type == "title":
        decoded_title_str = base64.b64decode(decoded_img).decode('utf-8')
        # 使用正则表达式和 y 函数替换多字节字符
        decoded_title_str = b.sub(lambda match: y(match.group(0)), decoded_title_str)
        return decoded_title_str
    else:
        return decoded_img


if __name__ == '__main__':
    # 标题测试样例
    title_pic = "4ntGHUqMtltGiVfsfSqLiVwLtwqFSsiK4lqHXHOMOFfLnUGsORG94HKF6XMSUHwLiG2pjVyt"
    decode_title_result = decode_captcha_title(title_pic, "title")
    logger.info(decode_title_result.replace("@", ""))

    # 背景图片测试样例
    bg_pic = "pictures[0]"  # 太长了, 自行替换测试
    decode_bg_result = decode_captcha_title(bg_pic)
    logger.info(decode_bg_result)

背景图片链接还原比标题少一步解码:

7AuzLG.png

第二种,直接搜索 decode,尝试定位解密算法的位置。跟栈进去后发现,
_app.js
文件内容未经过混淆,ctrl + f 搜索一下,把感觉像的地方都打上断点,刷新验证码,断住后,也能找到相关算法的位置:

7AuCzt.png

将还原的结果去掉前面的
data:image/png;base64,
(标识信息),复制到 K哥工具站(
https://www.kgtools.cn/convert/base64img)验证一下,无误:

7AujHe.png

captcha_collect 参数

总共有两个接口需要
captcha_collect
参数,分别是获取图片的接口
/api/phantom/obtain_captcha
和验证接口
/api/phantom/user_verify
,本文将逐一分析。

两个接口,和前文一样,各用一种方案分析。
/obtain_captcha
接口的
captcha_collect
参数,跟栈调试,和前文一样,下个条件断点
i.includes('0as')
,在 anti_content 参数生成之后,图片结果响应返回之前,单步往下调试:

7AuI6w.png

跟到下图处会发现,此时的
captcha_collect
参数是由
Oa.a.getPrepareToken()
参数生成的:

7AufQ6.png

h(m(JSON.stringify(e)), k, C)
方法生成了
captcha_collect
参数的值,e 包含了一些环境相关的信息,k、C 明显也经过了加密处理:

7AuivO.png

先跟到 m 方法中去看看,这里是将各环境参数组成的字符串,使用 Gzip 压缩算法处理后得到的结果:

7AusIQ.png

在 JavaScript 中,一般使用两种方式实现 Gzip 压缩,分别是 pako 库和 zlib 模块:

  • pako:JavaScript 库,支持浏览器端和 Node.js 环境。pako 被设计为轻量级且跨平台,因此可以在浏览器中直接使用,无需依赖额外的本地模块或工具。适用于:浏览器端应用、前端开发、客户端 JavaScript 压缩;
  • zlib:是 Node.js 自带的原生模块,专门用于在 Node.js 环境中处理压缩和解压操作。zlib 基于 C++ 库,性能上通常更好,适用于处理大规模的数据压缩任务。适用于:服务器端应用、Node.js 后端开发、处理大文件压缩。

直接使用 pako 库即可:

npm install pako
const pako = require('pako');
// Gzip 压缩明文
const compressedText = pako.gzip(text);
// 将压缩后的字节数组转换为字符串
String.fromCharCode.apply(null, new Uint8Array(compressedText));

接下来,分析 h 函数,其就在 m 函数上面,跟过去下断点,key 和 iv 对应前文提到的 k 和 C:

7Au6Lf.png

AES 加密算法(
https://www.kgtools.cn/secret/aes):

7AuFxc.png

k、C 定义在 getPrepareToken 函数上的 init 函数中,写的很明显了
aes_key

aes_iv

7AuZ43.png

是哪加密的呢?往上跟栈,发现 Za(c) 方法生成的
aes_key

aes_iv
,c 就是前文提到的
/api/phantom/vc_pre_ck_b
接口响应返回的 salt 值:

7Aue9j.png

跟进去,将代码扣下来即可,也可以用 Python 复现加密算法:

7AuoK5.png

第二个,
captcha_collect
参数,直接在
_app.js
文件中局部搜索定位,逐个下断分析。定位到下图处,c 就是点击的坐标,
Oa.a.getImageClickToken()
函数生成的就是验证接口的
captcha_collect
参数的值:

7Aw9Pm.png

跟进去,这里的
J(X["concat"]([B, U, $, V, H, q, G]))
就是将数组 X 和 [B, U, $, V, H, q, G] 拼接之后加密,得到
captcha_collect
的值,包含了环境、轨迹等参数,校验不严:

7Awk64.png

J 函数跟进去和第一个
captcha_collect
参数的加密方式一致,k、C 相同:

7AwTNh.png

至此,整个验证码的加密分析流程就结束了。

相关算法源码,会分享到知识星球当中,需要的小伙伴自取,仅供学习交流。

结果验证

7AwpIY.png

之前介绍过两个数据展示的组件,
st.dataframe

st.table

今天介绍的
st.data_editor
组件,除了展示数据的功能更加强大之外,还可以编辑数据。

1. 概要

st.data_editor
组件在数据展示和编辑中都发挥着独特且重要的作用。

首先,在数据展示方面,它的优势在于:

  1. 直观性
    :以表格形式展示数据,使得数据更加直观易懂。通过该组件可以方便地查看数据集的整体结构和细节
  2. 适应性
    :能够自动适应屏幕宽度,并支持水平或垂直滚动,确保用户能方便地浏览整个数据集
  3. 交互性
    :支持对数据进行排序、筛选和搜索等操作,增强了数据的可读性和交互性

在数据编辑方面,优势也很明显:

  1. 灵活性
    :支持类电子表格编辑,可以在界面上直接对数据进行增删改操作,无需通过代码实现,提高了数据编辑的灵活性
  2. 定制性
    :提供了丰富的列定制选项,如通过
    st.column_config
    可以设置列的标题、类型、格式以及编辑属性(如最小/最大值或步长)等,以满足不同场景下的数据编辑需求
  3. 多数据类型支持
    :支持多种数据类型的数据编辑,包括数值、文本、日期、时间等,使得数据编辑更加全面和便捷
  4. 状态管理
    :具有状态管理机制,可以记录用户的编辑操作,并在必要时进行回滚或提交。这使得数据编辑过程更加安全和可控

2. 基本用法

st.data_editor
组件多用在需要实时编辑数据的情况,它主要参数有:

名称 类型 说明
data - 任何适合渲染成表格的数据类型
width int 数据编辑器的宽度,单位 px
height int 数据编辑器的高度,单位 px
use_container_width bool 是否使用父容器宽度
hide_index bool 是否隐藏索引列
column_order [str] 指定列的显示顺序
column_config dict 配置列的显示方式,例如标题、可见性、类型或格式,以及编辑属性等等
num_rows str 控制用户是否可以在数据编辑器中添加和删除行
disabled bool 或 [str] 是否禁用编辑
key str 组件名称,具有唯一性

这里重点关注两个参数,
data

column_config

data
这个参数支持的数据类型非常宽泛,具体包括:
pandas.DataFrame
,
pandas.Series
,
pandas.Styler
,
pandas.Index
,
pyarrow.Table
,
numpy.ndarray
,
pyspark.sql.DataFrame
,
snowflake.snowpark.DataFrame
,
list
,
set
,
tuple
,
dict
等等。

几乎所有常用的存储数据的结构都包含了。

column_config
这个参数可以高度定制列的显示和编辑行为,极大的增强了
st.data_editor
的能力,

在后面的高级用法中会详细介绍。

2.1. 使用示例

下面通过一个根据实际应用简化的示例来演示
st.data_editor
基本用法。

我们构建一个简单的产品库存管理系统,通过一个界面来展示和编辑产品的库存数据。

上面用
st.data_editor
来编辑表格数据,下面用
st.dataframe
同步显示编辑后的数据。

import streamlit as st
import pandas as pd

# 模拟产品库存数据
inventory_data = pd.DataFrame(
    {
        "Product ID": [1, 2, 3],
        "Product Name": ["Apple", "Banana", "Cherry"],
        "Quantity": [100, 150, 200],
        "Price": [0.5, 0.3, 1.0],
    }
)

# 使用st.data_editor展示和编辑库存数据
st.title("产品库存管理系统")
edited_inventory = st.data_editor(
    inventory_data,
    width=600,
    height=300,
    num_rows="dynamic",
)

# 显示编辑后的数据(可选,此处仅为展示效果)
st.write("编辑后的库存数据:")
st.dataframe(edited_inventory)

3. 高级用法

结合
column_config
参数,我们可以高度定制
st.data_editor
的列的显示和编辑行为。

比如:

  1. 自定义列显示
    :设置列的标题、帮助信息、格式等,使数据编辑器的界面更加友好和易读
  2. 控制列编辑行为
    :指定列的数据类型、最小值、最大值、步长等,从而控制用户对该列的编辑行为。例如,可以将一列设置为数字类型,并限制其输入范围
  3. 添加特殊类型的列
    :支持多种特殊类型的列,如图表列、进度条列、链接列等,这些特殊类型的列可以提供更丰富的数据展示和编辑体验

3.1. 使用示例

下面我们构建一个学生成绩管理系统,使用
st.data_editor
展示和编辑学生成绩,

并通过
st.column_config
定制列的显示和编辑行为。

下面的示例中,定义了每个列的标题,同时定义了各科目的最高分和显示的
format

import streamlit as st
import pandas as pd

# 模拟学生成绩数据
student_grades = pd.DataFrame(
    {
        "Student Name": ["Alice", "Bob", "Charlie"],
        "Math": [125, 90, 140],
        "Science": [38, 20, 34],
        "English": [88, 114, 96],
    }
)

# 定义列配置
column_config = {
    "Student Name": {"label": "学生姓名"},
    "Math": st.column_config.NumberColumn(
        label="数学成绩",
        min_value=0,
        max_value=150,
        format="%d 分",
    ),
    "Science": st.column_config.NumberColumn(
        label="科学成绩",
        min_value=0,
        max_value=40,
        format="%d 分",
    ),
    "English": st.column_config.NumberColumn(
        label="英语成绩",
        min_value=0,
        max_value=120,
        format="%d 分",
    ),
}

# 使用st.data_editor展示和编辑学生成绩,并应用列配置
st.title("学生成绩管理系统")
edited_grades = st.data_editor(
    student_grades,
    width=500,
    height=300,
    column_config=column_config,
    num_rows="dynamic",
)

# 显示编辑后的数据(用于确认更改)
st.write("编辑后的学生成绩:")
st.dataframe(edited_grades)

下面构造一个更复杂的列的示例,其中使用了
下拉框

进度条

柱状图
作为列。

注意,
进度条

柱状图
的列是不能编辑的。

# 模拟项目数据
project_data = pd.DataFrame(
    {
        "Project Name": ["Project A", "Project B", "Project C"],
        "Status": ["In Progress", "Completed", "Pending"],
        "Progress": [60, 100, 30],  # 进度百分比
        "Sales": [
            [1000, 1500, 500],
            [200, 500, 1500],
            [1800, 500, 1000],
        ],  # 销售额
    }
)

# 定义状态选项列表
statuses = ["In Progress", "Completed", "Pending", "On Hold"]

# 定义列配置
column_config = {
    "Project Name": {"label": "项目名称"},
    "Status": st.column_config.SelectboxColumn(
        "状态",
        options=statuses,
    ),
    "Progress": st.column_config.ProgressColumn(
        "进度",
        min_value=0,
        max_value=100,
        format="%d%%",  # 显示百分比
    ),
    "Sales": st.column_config.BarChartColumn(
        "销售额变化",
        y_min=100,
        y_max=2000,
    ),
}

# 使用st.data_editor展示和编辑项目数据(不包含图表列)
st.title("项目管理系统")
edited_projects = st.data_editor(
    project_data,
    width=500,
    height=300,
    column_config=column_config,
    num_rows="dynamic",
)

4. 总结

总之,
st.data_editor

Streamlit
中一个功能强大的组件,它提供了类似电子表格的方式在线编辑
DataFrame
或其他类似结构的数据,直观易用。

然而,它也存在一些劣势,如
状态管理复杂性

性能问题
以及
定制化限制
等。

在实际应用中,需要根据具体需求权衡利弊,并结合
Streamlit
的其他组件和
Python
的强大生态来实现更复杂的数据处理和分析功能。

最近工作上写了个爬虫,要爬取国家标准网上的一些信息,这自然离不了 Python,而在解析 HTML 方面,
xpath
则可当仁不让的成为兵器谱第一。

你可能之前听说或用过其它的解析方式,像
Beautiful Soup
,用的人好像也不少,但
xpath
与之相比,语法更简单,解析速度更快,就像正则表达式一样,刚上手要学习一番,然而用久了,那些规则自然而然的就记住了,熟练之后也很难忘记。

安装 lxml

xpath
只是解析规则,其背后是要有相应的库来实现功能的,就像正则表达式只是规则,而 Python 内置的
re
库,则是提供了解析功能。在 Python 中,
lxml
就是 xpath 解析的实现库。

安装
lxml
非常简单,
pip install lxml
就搞定了。

下面我们来看一下,在我这次真实的项目中,该如何发挥出它的威力。

加载 HTML 内容,应该用 etree.parse()、etree.fromstring() 还是 etree.HTML() ?

首先,把 lxml 库导进来:
from lxml import etree

HTML 内容的加载,是通过 etree 的方法载入的,具体有 3 个方法:parse()、fromstring() 和 HTML()。

  • parse() 是从文件加载。
  • fromstring() 是从字符串加载。
  • HTML() 也是从字符串加载,但是以 HTML 兼容的方式加载进来的。

那我们应该选哪个方法呢?别犹豫,选
etree.HTML()
,即使你的 HTML 内容来自文件。这是为何?

首先要说的一点是,HTML 也是 XML 的一种,而 XML 的标准规定,其必须拥有一个根标签,否则,这段 XML 就是非法的。而我们加载进来的 HTML 内容,可能本身就不是完整的,只是个片段,且没有根标签;或是加载进来的 HTML 从头到脚看起来都是完整的,但是中间的节点,有的缺少结束标签,这些情况,其实都是非法的 XML。那么,在用 parse() 或 formstring() 加载这种缺胳膊少腿的 HTML 的时候,就会报错;而用
etree.HTML()
则不会。

这是因为 etree.HTML() 加载方式,有很好的 HTML 兼容性,它会补全缺胳膊少腿的 HTML,把它变成一个完整的、合法的 HTML。

下面是一个从文件加载 HTML 的例子:

from lxml import etree

with open('test.html', 'r') as f:
    html = etree.HTML(f.read())
    print(html, type(html))

打印出来的结果是:
<Element html at 0x7f7efa762040> <class 'lxml.etree._Element'>
,加载进来的 HTML 字符串,已经变成了
Element
对象。

后面我们通过 xpath 找 HTML 节点,全都是在这个
Element
对象上操作的。

找到你需要的 HTML 节点

下面是我想要找的 HTML 节点

在这个 table 表格中,第一个 tbody 是表头,第二个 tbody 是表内容,我们要如何定位到第二个 tbody ?

我们通常是调用上面获得的
Element
对象的
xpath()
方法,通过传入的 xpath 路径查找的。而路径有两种写法:一种是
/
开头,从
html
根标签,沿着子节点一个个找下来;另一种是
//
开头,即不论我们要找的节点在什么位置,找到就算,这种方式是最常用的。

比如,我们现在要找的
tbody
节点,它在
table
节点下,我们就可以这样写:
html.xpath('//table/tbody')
。这里的
html
是上面获得的
Element
对象,然后去找 HTML 内容中的、不管在任何位置的所有的
table
,找到后再继续找它们下面的直接子节点
tbody
,于是就匹配出来了。

可是这里有 2 个
tbody
,我需要的是第二个,我们可以在
[]
中写条件表达式:
html.xpath('//table/tbody[2]')
,注意这里的序号是从
1
开始的。

强大的属性选择器

你可能有个疑问,如果 HTML 内容中不只有一个
table
表格,那我们通过
html.xpath('//table/tbody[2]')
岂不是找到了 2 个
table
里的第二个
tbody
,而我需要的只是其中之一。没错,是存在这样一个问题。此时,我们就可以用属性选择器,来更精确的定位元素。

观察一下上面的 HTML 结构,
table
表格的最外层有一个
div
,它还有个 class 属性:
table-responsive
,假设这个 div 的 class 属性是整个 HTML 里独一无二的,那么我们就可以很放心的去查找
div.table-responsive
下的
table
,进而精确定位我们想要的元素。

那么,要怎样写
class = "table-responsive"
这个条件呢?看看上面写条件表达式的
[]
,那里面除了可以写数字来指定位置以外,也可以写其它各式各样的条件,比如:

html.xpath('//div[@class="table-responsive"]/table/tbody[2]')
,这里我们就把
class = "table-responsive"
这个条件写进去了,从而定位到想要的元素。注意,在 xpath 中,所有的 HTML 属性匹配都是以
@
打头的,比如有这样一个
<a id="show_me" href="#">Click Me</a>
元素,我们想要通过 id 定位它,可以这样写:
//a[@id="show_me"]
,是不是很简单。

假设很遗憾,我们这里的
table-responsive
不是唯一的,可能还有其它地方的 div 的 class = "table-responsive",这该怎么办?没关系,我们可以找其它具有唯一 class 值的元素,比如:最外层 div 下的
table.result_list
这个元素,这个是唯一的。好了,下面开始写定位代码:
html.xpath('//table[@class="result_list"]/tbody[2]')
,但是运行后,发现找不到元素,这是为什么?

其实仔细观察一下就能发现,这个元素的 class 里不只有
result_list
,它还包括其它一长串的内容:
class = "table result_list table-striped table-hover"
,所以匹配失败了。那要如何指定 class 包含某个属性呢?其实可以在条件表达式中,用
contains()
函数,无需精确匹配,而是模糊匹配,只要包含指定的字符串就可以了。比如:
html.xpath('//table[contains(@class, "result_list")]/tbody[2]')
这样就可以实现了。

需要提一点的是,xpath 定位到的元素,不管是不是全局唯一的,它的返回值都是一个列表,需要通过下标获取其中的元素。

相对定位

我最终的目标,是要遍历表格中所有的内容行,获取其中的标准号和标准名称,于是我初步完成了如下代码:

from lxml import etree

with open('test.html', 'r') as f:
    html = etree.HTML(f.read())
    rows = html.xpath('//table[contains(@class, "result_list")]/tbody[2]/tr')

    for row in rows:
        td_list = row.xpath('...')

现在我能够成功地定位到每一行,下面需要再基于每一行,找到我需要的列:

此时,我在 for 循环的内部,已经拿到了每一行
row
,再通过
row.xpath('//td')
继续往下定位
td
就好了。

可是,当你运行这段代码的时候,你会发现不对劲,一行里面总共只有 8 个 td,为什么出来了 80 个【一行 8 个,总共 10 行】?这是把 HTML 中所有的 td 都找出来了吧,可是我明明是用上面获取的
row
对象来查找的呀,不是应该只基于当前行往下找吗?

这就牵扯到了
绝对定位

相对定位

其实,我们上面讲到的
/

//
,都是绝对定位,也就是从 HTML 内容的根节点往下查找。一个 HTML 内容的根节点是什么呢,它是
html
,再往下是
body
,再再往下才是自定义的标签。所以,上面代码的执行结果是那种情况,也就不足为奇了,因为它不是在当前所在的
row
节点查找的,而是从根节点
/html/body/xxx/xxx/td
往下查找的呀。

所以,在这里不能用
绝对定位
了,要用
相对定位
,那要如何用?很简单,用
.

..
即可,这个我们可太熟悉了,
.
就代表了当前节点
row
,而
..
则代表了当前节点的上一层父节点
tbody

好了,我们修正上面的代码:

from lxml import etree

with open('test.html', 'r') as f:
    html = etree.HTML(f.read())
    rows = html.xpath('//table[contains(@class, "result_list")]/tbody[2]/tr')

    for row in rows:
        td_list = row.xpath('./td')

        i = 1
        for td in td_list:
            if i == 2:
                pass
            elif i == 4:
                pass
            i += 1

这样就可以正常地找到每一行里面的 8 个
td
,然后再单独处理第 2 个和第 4 个单元格,获取其中的信息就好了。

通过已知节点获取属性和文本

到目前为止,我们能拿到第 2 个和第 4 个
td
节点了,只要再获取里面的
a
标签的属性和文本就可以了。

我们先获取
onclick
属性,通过
td.xpath('./a')
,可以找到此
td
节点下面的
a
标签,然后调用
a
节点的
get()
方法,即可获得对应的属性值,代码如下:

a1 = td.xpath('./a')[0]
onclick = a1.get('onclick')

注意哦,
xpath()
方法的返回值,始终是一个列表,所以我们用下标
[0]
先把它从列表中取出来,然后再获取其属性。 至于属性内的值,我实际想取的是里面的一串 ID 字符串,这个再用正则表达式取一下就可以了。

要获取节点内的文本,也很简单,获得到的节点有一个
text
属性,可以直接得到节点的文本内容:
a1.text

好用的兄弟节点选择器

上面的代码逻辑有点挫,我们先是获取到一行里的所有
td
,然后循环遍历它,在遍历的过程当中,只取其中的 2 个
td
,着实有些浪费。假设一行里有 1000 个
td
,那这里岂不是要循环 1000 次,就只为了取 2 个?

虽然从实际运行速度上来讲,影响微乎其微,但对于有代码洁癖和强迫症的人来说,是不可接受的,所以,我们要改造它。

重新观察一下 HTML 结构,我发现第 4 个单元格有个明显的特征,它的
class = "mytxt"

我们可以很容易地找到它:
title_td = tr.xpath('./td[@class="mytxt"]')[0]
,然后再基于刚找到的
title_td
,查找从它往上数第 2 个兄弟节点,这样就省略了一个循环,只要查找两次就完成了。

那么,怎么查找上面的兄弟节点呢?用
preceing-sibling
,比如:
title_td.xpath('./preceding-sibling::td[2]')
,这就代表要查找
title_td
上面的、从它这里往上数、排在第 2 位的
td
节点。

除了
preceding-sibling
之外,还有
following-sibling
,顾名思意,是往下查找兄弟节点。

以上我只介绍了这 2 个,其实还有很多类似的选择器,具体可以参考下面的速查手册。

最后,我改造的代码如下:

from lxml import etree

with open('test.html', 'r') as f:
    html = etree.HTML(f.read())
    rows = html.xpath('//table[contains(@class, "result_list")]/tbody[2]/tr')

    for row in rows:
        title_td = row.xpath('./td[@class="mytxt"]')[0]
        title_link = title_td.xpath('./a')[0]
        title_onclick = title_link.get('onclick')

        print(title_onclick, title_link.text)

        id_td = title_td.xpath('./preceding-sibling::td[2]')[0]
        id_link_text = id_td.xpath('./a/text()')[0]

        print(id_link_text)

速查手册

xpath 的规则并不复杂,常用的也就那些,用熟了自然就记住了。但像正则表达式一样,它还有许多不常用却很好用的特性,你还是需要偶尔查一下具体的作用和用法。

这里有一个非常好的速查手册,虽然里面的内容看起来不够丰富、很简单,但是可以一目了然,并且它用 css 的语法来作类比,就能够更好地理解每一个 xpath 规则的实际用途。

速查手册:
https://devhints.io/xpath

简介

多线程与异步是两个完全不同的概念,常常有人混淆。

  1. 异步
    异步适用于"IO密集型"的场景,它可以避免因为
    线程等待IO
    形成的线程饥饿,从而造成程序吞吐量的降低。
    其本质是:
    让线程的cpu片不再浪费在等待上,
    期间可以去干其它的事情。
    要注意的是:Async不能加速程序的执行,它只能做到不阻塞线程。

  2. 多线程
    多线程适用于"CPU密集型",主要是为了更多的利用多核CPU来同时执行逻辑。将一个大任务分而治之,提高完成速度,进而提高程序的并发能力
    值得注意的是,如果过多使用线程同步,会降低多线程的使用效果

在计算机科学中,一个线程指的是在程序中一段连续的逻辑控制流。在业务很复杂的时候,一个线程无法满足现有业务需求,多线程编程就应运而生。

异步请求流程图(Windows)

image

  1. ReadAsync底层调用win32 API ReadFile
  2. ReadFile分配IRP数据结构(句柄,读取偏移量,用来填充的byte[]),
  3. 然后传递给windows内核中,
  4. windows把IRP添加到硬盘驱动的IRP队列中,线程不再阻塞,立刻返回到线程池中(在此期IRP尚未处理完成)
  5. 读取硬盘数据
  6. 返回硬盘数据并组装IRP数据
  7. 将IRP Enqueue IO Completion Port
  8. ThreadPool轮询Dequeue该端口,提取IRP
  9. 执行回调,如果没有回调这一步直接丢弃IRP数据

异步操作的核心:IO完成端口(IO Completion Port)

IO完成端口(IO Completion Port)是Windows操作系统的一个内核对象,专门用来解决异步IO的问题,C#中所有异步操作都依赖此端口。
其本质是一个发布订阅模式的队列

CLR在初始化时,创建一个IO Completion Port完成与硬件设备的绑定,使得硬件的驱动程序知道将IRP送到哪里去。

眼见为实:IO Completion Port真的存在吗?

        /// <summary>
        /// 创建IO完成端口
        /// </summary>
        [DllImport("kernel32.dll")]
        static extern nint CreateIoCompletionPort(nint FileHandle, nint ExistingCompletionPort, nint CompletionKey, int NumberOfConcurrentThreads);

        /// <summary>
        /// IO数据入列
        /// </summary>
        [DllImport("kernel32.dll")]
        static extern bool PostQueuedCompletionStatus(nint CompletionPort, int dwNumberOfBytesTransferred, nint dwCompletionKey, nint lpOverlapped);

        /// <summary>
        /// IO数据出列
        /// </summary>
        [DllImport("kernel32.dll")]
        static extern bool GetQueuedCompletionStatusEx(nint CompletionPort, out uint lpNumberOfBytes, out nint lpCompletionKey, out nint lpOverlapped, uint dwMilliseconds);

有兴趣的小伙伴可以玩一玩这个api.

眼见为实:异步API真的基于IO Completion Port吗?

众所周知,Task的底层是ThreadPool,那么答案一定在ThreadPool的源码中
No BB,上源码,IOCompletionPoller.Poll

            private void Poll()
            {
			//轮询调用GetQueuedCompletionStatusEx,获取IO数据。
                while (
                    Interop.Kernel32.GetQueuedCompletionStatusEx(
                        _port,
                        _nativeEvents,
                        NativeEventCapacity,
                        out int nativeEventCount,
                        Timeout.Infinite,
                        false))
                {
                    for (int i = 0; i < nativeEventCount; ++i)
                    {
                        Interop.Kernel32.OVERLAPPED_ENTRY* nativeEvent = &_nativeEvents[i];
                        if (nativeEvent->lpOverlapped != null) // shouldn't be null since null is not posted
                        {
						//把event事件和数据压入内部ConcurrentQueue队列,缓存起来。
						//.net 8之前的版本,直接就在这里执行回调了
                            _events.BatchEnqueue(new Event(nativeEvent->lpOverlapped, nativeEvent->dwNumberOfBytesTransferred));
                        }
                    }
					//压入线程池的highPriorityWorkItems队列
					//.net 8之后,由线程池执行回调
                    _events.CompleteBatchEnqueue();
                }

                ThrowHelper.ThrowApplicationException(Marshal.GetHRForLastWin32Error());
            }

C# 中的异步函数

        //一旦将方法标记为async,编译器就会将代码转换成状态机
        static async void Test()
        {
            //线程1进入,初始化client
            var httpClient = new HttpClient();
            //GetAsync内部分配一个Task对象
            var getTask = httpClient.GetAsync("https://www.baidu.com");
            //此时, aait操作符实际会在Task对象上调用ContinueWith,向它传递用于恢复状态机的方法,线程线程从Test()方法中返回
            //在未来某个时刻,IO Completion Port 完成网络IO入列,线程池通知Task对象,一个新线程会重新进入Test()方法,从await操作符的位置开始执行ContinueWith回调方法(也就是代码后面的内容)。
            var response = await getTask;
        }

编译器如何将异步函数转换为状态机?

https://www.cnblogs.com/JulianHuang/p/18137189
https://www.cnblogs.com/huangxincheng/p/13558006.html

分享几个写的不错的博文,偷懒一下。
核心是MoveNext函数,里面包含了根据状态机status而执行不同代码的模板代码.
一个Task最少要被调用两次MoveNext,第一次调用是主动触发初始化状态机,第二次调用是回调函数再次执行状态机

    public class GetStringAsync : IAsyncStateMachine
	{
	    public int state;
        private string html;
        private string taskResult;

        public AsyncTaskMethodBuilder builder;
        private TaskAwaiter<string> awaiter;
        /// <summary>
        /// 状态机机制
        /// </summary>
        public void MoveNext()
        {
            var localState = state;
            TaskAwaiter<string> localAwaiter = default(TaskAwaiter<string>);
            GetStringAsync localStateMachine;

            try
            {
                switch (localState)
                {
                    //第一次初始化 (publish)
                    case -1:
                        localAwaiter = Task.Run(() =>
                        {
                            Thread.Sleep(1000); //模拟网络IO耗时
                            var response = "<html><h1>百度</h1></html>";
                            return response;
                        }).GetAwaiter();//转为TaskAwaiter对象,内部实现INotifyCompletion接口,使得具备传入回调函数的能力

                        if (!localAwaiter.IsCompleted)
                        {
                            localState = state = 0;
                            awaiter = localAwaiter;
                            localStateMachine = this;
                            builder.AwaitUnsafeOnCompleted(ref localAwaiter, ref localStateMachine);//将当前注册机传入回调函数,当前线程返回线程池
                            return;
                        }
                        break;

                    //第二次异步完成的回调 (subscribe)
                    case 0:
                        localAwaiter = awaiter;
                        awaiter = default(TaskAwaiter<string>);
                        localState = state = -1;
                        break;
                }
				//等价于ContinueWith
                taskResult = localAwaiter.GetResult();
                html = taskResult;
                taskResult = null;
                Console.WriteLine($"GetStringAsync方法返回:{html}");
            }
            catch (Exception ex)
            {
                state = -2;
                html = null;
                builder.SetException(ex);//只有调用await/result才会抛出异常,否则会丢弃。
                return;
            }

            state = -2;
            html = null;
            builder.SetResult();
        }
}

异步方法的异常处理

当异步操作发生异常时,IO Completion Port会告诉程序,异步操作已经完成,但存在一个错误。不会跟常规异常一样直接从内核态抛出一个异常。
因此ThreadPool会拿到IRP数据,里面包含了异常信息。它自己也不会抛出来。而是调用SetException存储起来。
当你调用await/GetResult() 时才会真正的抛出异常。因为当你没有及时获取Task的异常时,它会被丢弃。你需要妥善处理未抛出的异常

眼见为实

        internal TResult GetResultCore(bool waitCompletionNotification)
        {
            // If the result has not been calculated yet, wait for it.
            if (!IsCompleted) InternalWait(Timeout.Infinite, default); // won't throw if task faulted or canceled; that's handled below

            // Notify the debugger of the wait completion if it's requested such a notification
            if (waitCompletionNotification) NotifyDebuggerOfWaitCompletionIfNecessary();

            // Throw an exception if appropriate.
            if (!IsCompletedSuccessfully) ThrowIfExceptional(includeTaskCanceledExceptions: true);

            // We shouldn't be here if the result has not been set.
            Debug.Assert(IsCompletedSuccessfully, "Task<T>.Result getter: Expected result to have been set.");

            return m_result!;
        }

ValueTask

在众多异步场景中,有些场景是,GetAsync()第一次需要异步IO等待,然后把结果缓存到静态变量里。接下来N次都是不需要异步IO等待的。直接可以同步完成。
比如说Entity Framework中的FindAsync().只有第一次会查询数据库,剩下的N次直接读取内存。
如果使用Task<Result> ,从状态机的源码也可以看到,创建一个Task对象花销不少且为引用类型。创建越多对GC压力越大。

为了减少这种场景下的性能消耗,可以使用ValueTask,它为结构体值类型,正常不需要从托管堆中分配内存。

  1. 如果异步操作不需要等待,可以同步完成,那么回调会被立刻调用,没有多余开销。
  2. 如果异步操作需要等待,那依旧会创建一个Task对象

它的出现纯粹为了性能。

眼见为实

上源码
System.Private.CoreLib\src\System\Runtime\CompilerServices\ValueTaskAwaiter.cs

        public TResult Result
        {
            get
            {
                object? obj = _obj;//Task对象

                if (obj == null)//Task完成后会置为null,大家猜一猜为什么要置为空?
                {
                    return _result!;//直接返回缓存的结果
                }

                if (obj is Task<TResult> t) //Task未完成,还是走Task逻辑不变
                {
                    TaskAwaiter.ValidateEnd(t);
                    return t.ResultOnSuccess;
                }

                return Unsafe.As<IValueTaskSource<TResult>>(obj).GetResult(_token);//去IValueTaskSource里找缓存的result
            }
        }