2024年2月

前言

经常有小伙伴在技术群里提问:
WPF有什么好用的UI组件库?
,今天大姚给大家推荐4款开源、美观的WPF UI组件库。

WPF介绍

WPF 是一个强大的桌面应用程序框架,用于构建具有丰富用户界面的 Windows 应用。它提供了灵活的布局、数据绑定、样式和模板、动画效果等功能,让开发者可以创建出吸引人且交互性强的应用程序。

HandyControl

HandyControl是一套WPF控件库,它几乎重写了所有原生样式,同时包含80余款自定义控件。使用HandyControl你可以轻松地创建一个美观的WPF应用程序,从而大大提高开发效率。

Panuon.WPF.UI

Panuon.WPF.UI 是一个适用于定制个性化UI界面的组件库。它能帮助你快速完成样式和控件的UI设计,而不必深入了解WPF的 ControlTemplate 、 Storyboard 等知识。

例如,在原生WPF中下,如果你想要修改 Button 按钮 控件的悬浮背景色,你需要修改按钮的 Style 属性,并编写 Trigger 和 Storyboard 来实现悬浮渐变效果。如果你想要更复杂的效果,你可能还需要编写内部的 ControlTemplate 模板。但现在, Panuon.WPF.UI 为你提供了一个更简单的方式。你只需要在 Button 按钮 控件上添加一条 pu:ButtonHelper.HoverBackground="#FF0000" 属性,即可实现背景色悬浮渐变到红色的效果。Panuon.WPF.UI为每一种控件都提供了大量的属性,使你能够方便地修改WPF中没有直接提供,但在UI设计中非常常用的效果,这有助于你快速地完成UI设计(尤其是在你有设计图的情况下)。如果你不清楚如何设计UI界面,你可以前往 UI中国 、 dribbble 等设计网站查看其他设计师的优秀作品。

AduSkin

AduSkin一款简单漂亮的WPF UI,融合多个开源框架组件,为个人定制的UI,可供学者参考和使用。

Layui-WPF

LayUI-WPF是一个WPF版的Layui前端UI样式库,该控件库参考了Web版本的LayUI风格,利用该控件库可以完成现代化UI客户端程序,让你的客户端看起来更加简洁丰富又不失美感。

优秀项目和框架精选


以上项目都已收录到C#/.NET/.NET Core优秀项目和框架精选中,关注优秀项目和框架精选能让你及时了解C#、.NET和.NET Core领域的最新动态和最佳实践,提高开发工作效率和质量。坑已挖,欢迎大家踊跃提交PR推荐或自荐(让优秀的项目和框架不被埋没

遍历用for还是foreach?这篇文章帮你轻松选择!

在编程的世界里,我们经常需要对数据进行循环处理,常用的两种方法就是:for循环和foreach循环。想象你站在一条装满宝贝的传送带前,你要亲手检查每一件宝贝。使用for循环就像是你亲手控制传送带的速度和方向,而使用foreach循环则是传送带自动运转,你只需专注于宝贝本身。好,下面就让我们一步步深入了解下这两种方法吧!

应用场景

for循环
:好比你手握一张购物清单(索引),按照顺序逐项挑选商品。在数组、列表等数据结构中,for循环通过下标访问元素。这意味着,当你需要特定的遍历顺序,或者想要在循环中更改计数器时,for循环就是你的菜。

foreach循环:
更像是一个自动售货机,你只需站在出口等待,它会按顺序一个个送出商品。foreach适用于不需要关心索引,仅需遍历并处理每个元素的情况。尤其在处理集合类时,foreach更显简洁高效。

使用方法

我们用一个例子来感受一下for和foreach吧。假设你是个游戏玩家,你有一排宝箱需要打开。

for循环的使用
:在for循环中,通常会定义一个迭代变量,并指定迭代变量的初始值、循环条件和迭代变量的更新方式,在循环体中根据索引值访问数组或列表中的元素。

let treasureChests = ['金币', '宝石', '魔法药水', '地图', '钥匙'];
// 使用for循环打开每个宝箱
for (let i = 0; i < treasureChests.length; i++) {
    openChest(treasureChests[i]); // 打开宝箱
}

在这段JavaScript代码里,i就像是你手里的遥控器,从0开始按,一直按到最后一个宝箱。

foreach循环的使用
:foreach简化了迭代过程,不需要显式地定义迭代变量和更新迭代变量,也就是无需手动管理索引,编译器会自动帮我们完成元素的迭代获取。

treasureChests.forEach((chest) => {
    openChest(chest); // 打开宝箱
});

这里的foreach循环直接告诉你“这是个宝箱”,然后你就打开它。注意,我们这里没有使用索引,它是自动遍历数组中的每个元素。

注意在大多数现代编程语言中,foreach 循环(或其等效的遍历结构)设计的初衷是用来读取集合中的元素,而不是用于修改集合本身,因此我们无法在 foreach 循环中直接更改集合中对象的引用,但是我们可以修改对象中的属性。

C#的例子:

foreach (var item in collection)
{
    item.Property = newValue; // 允许修改对象的属性
    // item = new Object(); // 错误!不允许修改对象的引用
}

还需要注意如果集合中的元素是值类型或者基本数据类型,如int、double、string等,
当你在foreach循环中迭代时,由于每次迭代获取的是该元素的一个副本,因此直接修改这个副本不会影响原数组中的元素。

let numbers = [1, 2, 3];

numbers.forEach(item => {
  item = 4; // 这不会改变原始数组
});

console.log(numbers); // [1, 2, 3]

底层原理

for循环像是有条不紊的工厂流水线。在每次迭代中,都有一个明确的开始(初始化表达式),一个持续条件(条件表达式),和一个精确的进度控制(迭代表达式)。这个流水线会在你设定的条件下反复运转,直到任务完成。

foreach循环则更像是智能的机器人,它内置了遍历的逻辑。在像Java、C#这样的语言中,foreach循环背后是基于Iterable接口的。只要集合实现了Iterable接口,就可以用foreach来遍历。机器人(foreach循环)会自动调用集合的iterator方法,获取一个迭代器,然后通过这个迭代器遍历集合中的每个元素。

编程思想

for循环体现的是一种经典的命令式编程思想,它关注如何通过明确的步骤去解决问题。你需要告诉程序每一个要执行的动作,这种方式给予了程序员高度的控制权,但同时也增加了复杂性和出错的可能性。

foreach循环则是声明式编程的体现,更关注做什么而不是怎么做。你只需要声明你的需求(遍历集合),具体的遍历逻辑则被抽象掉了。这使得代码更简洁,也更易于阅读和维护,但牺牲了一些控制力。

执行效率

有的同学可能对性能比较关心。就执行速度而言,for 和 foreach 循环的效率差异通常是微不足道的,特别是在现代编译器和解释器优化的情况下。但是,还是有一些细微的差别:

  • for循环
    :在某些情况下,
    for
    循环可能略微更快,因为它的控制结构很简单(通常是一个索引和一个结束条件的比较)。如果你在循环中需要使用索引,或者你需要逆序遍历,或者以非标准的步长遍历,使用
    for
    循环可以直接满足这些需求而无需额外的计算或间接的访问。
  • foreach循环

    foreach
    循环通常提供了对集合的简化访问,隐藏了迭代的细节。在一些语言中,
    foreach
    循环背后可能使用了迭代器或者其他机制,这可能引入了轻微的性能开销。不过,对于只读操作或者不需要索引的情况,这个开销通常是可以忽略不计的。

在实际应用中,除非你正在编写非常性能敏感的代码,否则循环的选择应该更多地基于代码的清晰度和可维护性,而不是微小的性能差异。在大多数情况下,foreach 循环提供了更简洁、更易读的代码,尤其是当遍历集合而不需要索引时。

总结

for循环就像是多功能的瑞士军刀,适合于那些需要精确控制循环过程的场景。你可以自由地选择起点和终点,甚至可以逆向遍历或调整步长。

foreach循环则像是专一的榔头,对于简单地遍历集合来说,使用起来既快捷又高效。它让你免去了处理索引的烦恼,让你可以专注于元素本身。

编程不仅仅是关于写代码,更是关于选择合适的工具来解决问题。for和foreach就像是你工具箱里的两把锤子,它们各有所长,懂得在不同的情境下选择合适的一把,能让你的编程之路更加顺畅。

关注萤火架构,加速技术提升!

代码

原文地址

摘要

文档级关系抽取(DocRE)旨在从文档中抽取出所有实体对的关系。DocRE 面临的一个主要难题是实体对关系之间的复杂依赖性。与大部分隐式地学习强大表示的现有方法不同,最新的
LogiRE
通过学习逻辑规则来显式地建模这种依赖性。但是,LogiRE 需要在训练好骨干网络之后,再用额外的参数化模块进行推理,这种分开的优化过程可能导致结果不够理想。本文提出了 MILR,一个利用挖掘和注入逻辑规则来提升 DocRE 的逻辑框架。MILR 首先基于频率从标注中挖掘出逻辑规则。然后在训练过程中,使用一致性正则化作为辅助损失函数,来惩罚那些违反挖掘规则的样本。最后,MILR 基于整数规划从全局视角进行推理。与 LogiRE 相比,MILR 没有引入任何额外的参数,并且在训练和推理过程中都使用了逻辑规则。在两个基准数据集上的大量实验表明,MILR 不仅提升了关系抽取的性能(1.1%-3.8% F1),而且使预测更加符合逻辑(超过 4.5% Logic)。更重要的是,MILR 在这两个方面都显著优于 LogiRE。

1 Introduction

文档级关系抽取(DocRE):旨在从文档中识别出所有实体对之间的关系。
DocRE 面临的一个主要挑战:
实体对之间的关系并非是独立的,而是存在着复杂的依赖关系
。例如,在图 1 中,文本只直接表达了 Alisher 是 Chusovitina 的孩子,以及 Bakhodir 和 Chusovitina 是夫妻。但是,根据关系之间的常见依赖关系,可以用图 1 中的逻辑规则来表示,这两个事实就能推导出许多隐含的事实(比如,Alisher 是 Bakhodir 的孩子)。
为了捕获实体对之间的依赖关系,大部分之前的工作都侧重于利用精细的神经网络,如预训练语言模型或图神经网络来学习强大的表示。尽管这些模型取得了很大的成功,但它们缺乏透明性,而且在需要逻辑推理的情况下仍然容易出错。比如,图 1 还展示了一个最先进的 DocRE 模型
ATLOP
的预测结果。可以看到,ATLOP 只提取了一些显式的事实,如 spouse_of(Chusovitina, Bakhodir),而没有识别出一些隐含的事实,如 parent_of(Bakhodir, Alisher)。实际上,
这些隐含的事实可以通过显式地考虑关系之间的逻辑规则来更容易地识别
(比如,parent_of(v0, v2) ← spouse_of(v0, v1) ∧ parent_of(v1, v2))。基于此,LogiRE提出了一种方法,它基于训练好的 DocRE 模型(即骨干网络)的输出对数来生成逻辑规则,并通过对规则进行推理来重新提取关系。然而,LogiRE 需要在训练好骨干网络之后,再使用额外的参数化模块来进行推理,这种分离的优化过程可能导致结果不够理想。比如,LogiRE 不能在训练过程中让骨干网络具有逻辑一致性的感知,而且可能导致错误的累积。
为了提升DocRE的效果,本文提出了一个通用的框架 MILR,它能够挖掘和注入逻辑规则。由于现有的逻辑规则不够明确,MILR 首先根据训练集上的条件相对频率来发现逻辑规则。然后,它利用一致性正则化作为一个辅助损失函数,对违反发现规则的训练实例进行惩罚。一致性正则化和常见的分类损失函数相结合,用于训练骨干网络。最后,MILR 采用了一种基于 0-1 整数规划的全局推理方法,它可以视为在逻辑约束条件下对基于阈值的推理方法的一种扩展。这样,MILR 能够使骨干网络在训练和预测时将所有关系作为一个整体来处理,显式地捕捉关系之间的依赖性,从而增强解释性。

2 Preliminaries

Problem Formulation

DocRE的目的是从完整的文档中找出多个实体之间的语义关系。给定一个文档

,其中包含

个命名实体

,DocRE 需要预测每一对不同的实体

之间的关系类型。关系类型的集合是

,其中

是预先定义好的,

表示“无关系”。DocRE比句子级关系抽取更具挑战性,因为它需要综合利用文档中多个句子的信息,并处理跨句实体之间的复杂依赖关系。

Atoms and Rules

一个原子

(或

) 是一个二元变量,表示头实体

和尾实体

之间是否存在关系

。如果存在,

。否则

规则是一个具有如下形式的合取公式:
其中


是表示任意实体的变量,

是规则的长度。



分别称为头原子和体原子。本文采用概率软逻辑 (
Kimmig 等人,

2012
;
Bach 等人, 2017
) 的框架,给每个规则赋予一个置信度属性,其值在 [0, 1] 区间内。一个规则

可以被看作是一个模板,它可以通过将

从变量替换为特定的实体

来实例化(记为

)。如果

的所有体原子都成立,称

是一个由

推导出的预测,即预测头原子由于

而成立。注意,一个不合理的规则可能没有对应的预测,因为它的体原子不可能同时成立。

Paradigm of Backbones

对于每个原子



表示其对数几率。通过sigmoid函数,

可以用来估计在给定

的条件下,关系

是否成立的概率,即
其中

是sigmoid函数。
为了训练模型,使用分类损失函数(例如,二元交叉熵(BCE)损失或自适应阈值损失来优化目标函数(即

)。
在推理过程中,

通过将预测概率与分类阈值进行比较来确定

的预测关系:
其中

表示

是一个预测事实,反之则否,

表示指示函数,



的分类阈值。常见的基于阈值的推理方法有全局阈值法(
Yao等,2019

Zeng等,2020
)和自适应阈值法(
Zhou等,2021a

Yang Zhou等,2022
)。这两种方法的主要区别在于

是否与

相关。

3 Methodology

本文提出了一种与模型无关的框架 MILR,它能够让现有的 DocRE 模型在训练和推理时具有逻辑一致性。MILR 的核心思想是,既要约束输出的对数几率,也要约束最终的预测,使它们符合逻辑规则。由于大多数数据集没有提供金标准的逻辑规则,MILR 采用了一种从关系标注中直接挖掘规则的数据驱动方法(见第 3.1 节)。在训练时,MILR 通过一致性正则化作为一个辅助损失,来惩罚那些违反挖掘规则的实例(见第 3.2 节)。在推理时,MILR 将对数几率和挖掘规则结合起来,进行全局预测(见第 3.3 节)。最后,第 3.4 节对 MILR 和 LogiRE 进行了详细的比较。

3.1 Rule Mining

受知识库和知识图谱相关工作的启发,MILR 采用了一种基于频率的简单而有效的方法来挖掘逻辑规则。直观地说,如果一个规则能够反映关系之间的依赖性,例如 child_of(v0, v1) ←
parent_of(v1, v0),那么它的头原子就倾向于与它的体原子同时出现。此外,
一个规则的置信度可以通过当体原子成立时,头原子成立的条件概率来估计
本文采用了闭世界假设(CWA),即认为任何不在关系标注中的原子都是反例
。在 CWA 下,如果一个规则

的预测

的头原子在标注中,就称

为真预测。否则,称之为假预测。一个规则

的置信度定义为所有预测中真预测的比例:
其中



的缩写,



分别是规则

在训练集中的真预测和假预测的数量。公式 4 可以看作是用条件相对频率来估计条件概率。注意,如果一个规则

没有预测,

被设为 0。
规则挖掘器(RM)以训练集的标注

、扩展的关系集

、构造规则的最大长度

和过滤荒谬规则的最小置信度

作为输入。如算法 1 所示,RM 枚举所有可能的规则(第 2-4 行)。在枚举过程中,RM 根据公式 4 计算

(第 5 行)。如果

高于

,RM 将

和相应的

添加到输出中(第 6-7 行)。

3.2 Consistency Regularization

为了统一离散的约束和现有的 DocRE 模型的损失驱动的学习范式,本文需要解决一个关键的技术问题:
如何在具有置信度的逻辑规则下进行推理
。本文借鉴了乘积 t-范数的思想,将一个规则 R 的理想概率形式定义为
其中



的长度,

是一个与

相关的松弛超参数,

是由公式 2 计算的输出概率。在这个定义下,
如果一个规则的置信度很高(接近 1),那么它的头原子的概率应该不低于它的体原子的联合概率
,这里简单地用

来近似。这意味着
规则的头原子可以由它的体原子或其他途径推出,比如明确的上下文或其他有相同头原子的规则
。随着置信度的降低,这个约束也会相应地放宽。(本文定义

然而,如果没有正则化,上述规则的理想概率形式在训练骨干网络时很可能被破坏,特别是当头原子的关系类型是不常见的时候(
Huang 等人, 2022
)。因此,本文认为,除了 DocRE 模型的原始分类损失

外,还有另一个与逻辑一致性相关的损失

,应该被最小化。为了将



都放在概率的对数空间中,给定一个文档

,将

表示为

枚举了所有实例化的规则,并正则化相应的对数值,使其满足公式 5 定义的理想形式。如果规则的理想概率形式几乎被满足,那么一致性正则化损失

及其梯度都很小,因此对骨干网络的训练影响不大。如果不是,

将在训练中产生很大的梯度幅度,从而正则化骨干网络以满足逻辑一致性。
总之,MILR 中的训练目标是
其中

是一个用于平衡两个损失的超参数。通过这种方式,学习过程试图统一单个原子的似然性质和多个关系之间的逻辑性质,从而支持骨干网络全面理解给定的注释。

3.3 Global Inference

尽管在训练过程中,已经注入了逻辑规则,但骨干网络在推理过程中仍有可能产生违反逻辑规则的预测。为了解决这个问题,MILR 采用了一种基于编程的方法,在推理过程中强制执行逻辑规则,从而实现了一种全局推理方法。这种方法可以看作是公式 3 中提到的基于阈值的方法的一种改进。为了便于理解,先回顾一下基于阈值的方法,并从 0-1 整数规划的角度进行分析:
Fact 1.


为一个 DocRE 模型,

为输出的对数值,

为阈值,

为原子

的预测结果,


。对于以下问题:
一个最优解是

,其中

。证明见附录 A。目标函数的构造受到了 BCE 损失函数的启发。因此,基于阈值的方法可以被看作是利用潜在的预测结果

作为二元决策变量,无约束地最小化分布

相对于分布

的交叉熵之和。
这种观点激发了本文将逻辑规则作为编程问题的约束条件。直观地说,对于一个规则

,逻辑一致性要求它的预测体原子都成立,那么它的预测头原子也成立。如果任何一个体原子失败,逻辑一致性对预测头原子没有约束。这可以用数学表达为


。添加这些逻辑约束和对称约束,就可以得到全局推理方法的原始形式:
这种原始形式的目标是利用推理结果在逻辑约束下最小化 BCE 损失,与公式 7 定义的训练目标相一致。可以用分支定界法 (
Lawler 和 Wood, 1966
) 或现成的优化器如 Gurobi (
Gurobi Optimization, LLC, 2022
) 来求解这种原始形式。
但是,这个问题涉及

个逻辑约束,其中

是实体的数量。这些冗余的约束会导致计算速度非常慢。为了解决这个问题,本文提出了一种启发式策略来简化约束,具体见算法 2。该策略的思想是,
只对那些由基于阈值的方法预测为真的体原子的预测施加逻辑约束,用逻辑规则来修正它们和相应的头原子
。而其他原子的预测结果则保持与通过阈值化概率产生的银标签一致。从数学上看,这种策略相当于对最优解处的正约束做了近似。这样做的好处是,由于大多数实体对没有关系,约束的数量可以大大减少。
在评估模型时,本文发现添加补偿项来构造目标函数可以进一步提高性能。修改后的目标函数如下:
其中,

是超参数,

是在训练集上评估的关系

的频率。这些补偿项可以帮助缓解DocRE的类不平衡问题。
总之,最终的全局推理形式以公式10为目标,并利用算法2构造逻辑约束集合。基于整数规划,可以过滤掉低概率的逻辑不一致,从而提高性能和可解释性。

3.4 Comparison with LogiRE

LogiRE 和 MILR 都是将逻辑规则注入到主干网络的方法,但 MILR 有以下三个优势。首先,MILR 不需要额外训练任何模块,因此更加高效。其次,MILR 在训练过程中利用一致性正则化,使主干网络具有逻辑一致性的能力。而 LogiRE 不改变训练过程,所以主干网络更容易受到噪声标签的影响,这在 DocRE (
Huang等人, 2022
) 中是比较常见的情况。第三,MILR 可以处理更多种类的错误,这些错误是根据它们发生在逻辑规则的哪个部分来分类的。MILR 在推理过程中采用了一种基于编程的方法,可以在理论上减少头原子的假阴性 (FNH) 和体原子的假阳性 (FPB)。相反,LogiRE 只能处理 FNH,因为 LogiRE 是通过元路径来计算要评估的原子的最终逻辑值的,这些逻辑值可能被主干网络误导。当 LogiRE 遇到 FPB (即主干网络错误地为不成立的三元组输出了高逻辑值) 时,LogiRE 就会盲目地认为这些逻辑值是真阳性,从而导致头原子的假阳性 (FPH)。值得注意的是,MILR 和 LogiRE 都无法处理 FPH 和体原子的假阴性 (FNB),因为这些情况下没有什么可以推理的,逻辑约束已经被满足了。为了方便理解,本文在表1中对上述讨论进行了总结。

​ 在我们生产环境中使用到了地图服务,每个月有免费请求次数,近一个月请求次数突然暴涨,导致直接开启付费模式,一个月上百刀的花销着实难扛,根据实际我们的业务使用情况,远达不到付费标准,故考虑做白名单和限流措施,基于以上情况并遇到春节急需快速处理,所以选择了最简单方便的方式,通过nginx做限流

​ 我们都知道nginx里面是可以用lua脚本做一些稍微复杂些的逻辑处理的,要使用lua脚本需要编译lua解释器,时间有限我直接用了openresty,它集成了lua和nginx

1、openresty是什么?

OpenResty是一个基于Nginx的高性能Web平台,用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。具备下列特点:

具备Nginx的完整功能
基于Lua语言进行扩展,集成了大量精良的 Lua 库、第三方模块,允许使用Lua自定义业务逻辑、自定义库

二、OpenResty的安装

1、添加OpenResty仓库

# 由于公共库中找不到openresty,所以需要添加openresty的源仓库
yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo

# 注意,如果上面命令提示不存在,那就先安装一下
yum install -y yum-utils

2. 安装OpenResty

# 安装openresty
yum install -y openresty
# 安装OpenResty管理工具,帮助我们安装第三方的Lua模块
yum install -y openresty-opm

3、目录结构

​ 默认安装在/usr/local/openresty

看到里面有一个nginx目录,进去可以看到跟我们平常用的nginx是一模一样的,OpenResty就是在Nginx基础上集成了一些Lua模块

到这里我们就安装好了

7. 启动和运行

OpenResty底层是基于Nginx的,查看OpenResty目录的nginx目录,结构与windows中安装的nginx基本一致:

所以这个里面的nginx和平常的nginx是一样的

1)nginx配置文件
worker_processes 1;
events {
    worker_connections 1024;
}
http {
    server{
        listen 999;
        server_name  localhost;
        location /mapbox/ {
            access_by_lua_file "/usr/local/openresty/nginx/lua_script/rule.lua";
            proxy_pass https://api.mapbox.com/;
            proxy_ssl_server_name on;
        }
    }
}

2)lua脚本文件(白名单加限流)

通过两个redis的key,map_request_limitation:存放令牌数量,map_request_white_list:白名单列表;白名单的IP,无需限流,只有白名单之外的才需要限流

-- 其实这两个值可以从redis取  甚至可以给每个qrcode设置单独的速率和容积
-- 但如果想监听桶的状态  需要持续的请求, 只有每次请求后才重新计算并更新桶状态 否则桶状态不变
local tokens_per_second = 0.2  -- 生成速率 /s
local max_tokens = 10  -- 最大溶剂

local current_time = ngx.now()
local path = ngx.var.uri
local redis_key = "map_request_limitation"
local redis_key_white_list = "map_request_white_list"

local client_ip = ngx.var.remote_addr
-- local redis_key = "path:" .. path

-- 连接Redis
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
    ngx.log(ngx.ERR, "Redis连接失败: ", err)
    return ngx.exit(500)
end
-- 权限校验
local res, err = red:auth("123456")
if not res then
    ngx.say("failed to authenticate: ", err)
    return
end
-- 发送 Lua 脚本(保证redis原子性操作)
local script = [[

    local redis_key = KEYS[1]
    local redis_white_list_key = KEYS[2]
    local tokens_per_second = tonumber(ARGV[1])
    local max_tokens = tonumber(ARGV[2])
    local current_time = tonumber(ARGV[3])
    local client_ip = ARGV[4]
    
    -- ip是否存在列表中
    local is_in_whitelist, err = redis.call('sismember', redis_white_list_key, client_ip)
    if is_in_whitelist == 1 then
        return 1
    end

    -- 获取上次访问时间和令牌数量
    local res = redis.call('HMGET', redis_key, 'last_access_time', 'tokens')
    local last_access_time
    local last_tokens
    if res[1] and res[2] then
	last_tokens = res[2]
	last_access_time =  res[1]
    end

    -- 计算时间间隔
    local time_passed = current_time - (tonumber(last_access_time) or 0)

    -- 计算新的令牌数量
    last_tokens = last_tokens and tonumber(last_tokens) or max_tokens
    local new_tokens = math.min(max_tokens, last_tokens + time_passed * tokens_per_second)

    -- 判断令牌数量是否足够
    if new_tokens >= 1 then
        -- 消耗令牌
        redis.call('HMSET', redis_key, 'last_access_time', current_time, 'tokens', new_tokens - 1)
        return 1
    else
        return 0
    end
]]

-- 执行脚本
local result = red:eval(script, 2, redis_key, redis_key_white_list,tokens_per_second, max_tokens,current_time,client_ip)

if result == 1 then
    -- 成功
    ngx.log(ngx.INFO, "成功")
else
    -- 令牌不足
    ngx.status = ngx.HTTP_TOO_MANY_REQUESTS
    ngx.say("OVERLOAD!!!!",result)
    return ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end

-- 返还redis连接到连接池
local ok, err = red:set_keepalive(10000, 100)
if not ok then
    ngx.log(ngx.ERR, err)
end

启动之后当通过这个999端口访问之后,我们在redis里面可以看到以下两个key,白名单可以自行添加,即时生效

一、简介

在很多场景下,我们经常听到采用
多线程编程
,能显著的提升程序的执行效率。例如执行大批量数据的插入操作,采用单线程编程进行插入可能需要 30 分钟,采用多线程编程进行插入可能只需要 5 分钟就够了。

既然多线程编程技术如此厉害,那什么是多线程呢?

在介绍多线程之前,我们还得先讲讲进程和线程的概念。

二、进程和线程

2.1、什么是进程?

从计算机角度来讲,
进程是操作系统中的基本执行单元,也是操作系统进行资源分配和调度的基本单位,并且进程之间相互独立,互不干扰

例如,我们
windows
电脑中的 Chrome 浏览器是一个进程、WeChat 也是一个进程,正在操作系统中运行的
.exe
都可以理解为一个进程。

2.2、什么是线程?

关于线程,比较官方的定义是,
线程是进程中的⼀个执⾏单元,也是操作系统能够进行运算调度的最小单位,负责当前进程中程序的执⾏
。同时⼀个进程中⾄少有⼀个线程,⼀个进程中也可以有多个线程,它们共享这个进程的资源,拥有多个线程的程序,我们也称为多线程编程。

举个例子,Chrome 浏览器和 WeChat 是两个进程,Chrome 浏览器进程里面有很多线程,例如 HTTP 请求线程、事件响应线程、渲染线程等等,线程的并发执行使得在浏览器中点击一个新链接从而发起 HTTP 请求时,浏览器还可以响应用户的其它事件。

2.3、进程和线程的关系

关于进程和线程,可能上面的解释过于抽象,还是很难理解,下面是一段出自
阮一峰老师博客文章
的介绍,可能描述不是非常严谨,但是足够形象,有助于我们对它们关系的理解。

  • 1.我们都知道,计算机的核心是 CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行;(
    CPU 类似于工厂
  • 2.假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是,单个 CPU 一次只能运行一个任务;
  • 3.进程就好比工厂的车间,它代表 CPU 所能处理的单个任务。任一时刻,CPU 总是运行一个进程,其他进程处于非运行状态;(
    进程类似于车间
  • 4.一个车间里,可以有很多工人。他们协同完成一个任务;
  • 5.线程就好比车间里的工人。一个进程可以包括多个线程;(
    线程类似于工人
  • 6.车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存;(
    每个线程共享进程下的内存资源
  • 7.一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫"互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域;(
    多个线程下可以通过互斥锁,实现资源独占
  • 8.还有些房间,可以同时容纳 n 个人,比如厨房。也就是说,如果人数大于 n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用;
  • 9.这时的解决方法,就是在门口挂 n 把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做 "信号量"(Semaphore),用来保证多个线程不会互相冲突。(
    多个线程下可以通过信号量,实现互不冲突

不难看出,互斥锁 Mutex 是信号量 semaphore 的一种特殊情况(n = 1时)。也就是说,完全可以用后者替代前者。但是,因为 Mutex 较为简单,且效率高,所以在必须保证资源独占的情况下,还是采用这种方式。

2.4、为什么要引入线程?

早期的操作系统都是以进程作为独立运行的基本单位的,直到后期计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。

那为什么要引入线程呢?我们只需要记住这句话:
线程又称为迷你进程,但是它比进程更容易创建,也更容易撤销

引入线程之后,
可以将复杂的操作进一步分解,让程序的执行效率进一步提升

举个例子,进程就如同一个随时背着粮草和机枪的士兵,这样肯定会造成士兵的执行战斗的速度。因此,一个简单想法就是:分配两个人来执行,一个士兵负责随时背着粮草,另一个士兵负责抗机枪战斗,这样执行战斗的速度会大幅提升。这些轻装上阵的士兵,可以理解为我们上文提到的线程!

从计算机角度来说,由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,需要较大的时间和空间开销。

为了减少进程切换的开销,把进程作为资源分配单位和调度单位这两个属性分开处理,即进程还是作为资源分配的基本单位,但是把调度执行与切换的责任交给线程,即线程成为独立调度的基本单位,它比进程更容易(更快)创建,也更容易撤销。

一句话总结就是:引入线程前,进程是资源分配和独立调度的基本单位。引入线程后,进程是资源分配的基本单位,线程是独立调度的基本单位,线程也是进程中的⼀个执⾏单元。

三、创建线程的方式

在 Java 里面,创建线程有以下两种方式:

  • 继承
    java.lang.Thread
    类,重写
    run()
    方法
  • 实现
    java.lang.Runnable
    接口,然后通过一个
    java.lang.Thread
    类来启动

不管是哪种方式,所有的线程对象都必须是
Thread
类或其⼦类的实例,每个线程的作⽤是完成⼀定的任务,实际上就是执⾏⼀段程序流,即⼀段顺序执⾏的代码,任务执行完毕之后就结束了。

在 Java 中,通过
Thread
类来创建并启动线程的步骤如下:

  • 1.定义
    Thread
    类的⼦类,并重写该类的
    run()
    方法
  • 2.通过
    Thread
    子类,初始化线程对象
  • 3.通过线程对象,调用
    start()
    方法启动线程

下面我们具体来看看创建线程的代码实践。

3.1、继承 Thread 类,重写 run 方法介绍

/**
 * 创建一个 Thread 子类
 */
public class Thread0 extends Thread {

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
            System.out.println(time + " 当前线程:" + Thread.currentThread().getName() + ",正在运行");
        }
    }
}
/**
 * 创建一个测试类
 */
public class ThreadTest0 {

    public static void main(String[] args) {
        // 初始化一个线程对象,然后启动线程
        Thread0 thread0 = new Thread0();
        thread0.start();

        for (int i = 0; i < 5; i++) {
            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
            System.out.println(time + " 当前线程:" + Thread.currentThread().getName() + ",正在运行");
        }
    }
}

输出结果:

2023-08-23 17:58:03:726 当前线程:Thread-0,正在运行
2023-08-23 17:58:03:727 当前线程:Thread-0,正在运行
2023-08-23 17:58:03:726 当前线程:main,正在运行
2023-08-23 17:58:03:727 当前线程:Thread-0,正在运行
2023-08-23 17:58:03:727 当前线程:main,正在运行
2023-08-23 17:58:03:728 当前线程:Thread-0,正在运行
2023-08-23 17:58:03:728 当前线程:main,正在运行
2023-08-23 17:58:03:728 当前线程:Thread-0,正在运行
2023-08-23 17:58:03:728 当前线程:main,正在运行
2023-08-23 17:58:03:728 当前线程:main,正在运行

从执行时间上可以看到,
main
线程和
Thread-0
线程交替运行,效果十分明显!

所谓的多线程,其实就是两个及以上线程的代码可以同时运行,而不必一个线程需要等待另一个线程内的代码执行完才可以运行。

对于单核 CPU 来说,是无法做到真正的多线程的;但是对于多核 CPU 来说,在一段时间内,可以执行多个任务的,由于 CPU 执行代码时间很快,所以两个线程的代码交替执行看起来像是同时执行的一样,具体执行某段代码多少时间,就和分时机制系统有关了。

分时机制系统,简单的说,就是将 CPU 时间划分为多个时间片,操作系统以时间片为单位来执行各个线程的代码,越好的 CPU 分出的时间片越小。

例如某个时段, CPU 将 1 秒划分成 50 个时间片,1 个时间片耗时 20 ms,每个时间片均进行线程切换,也就是说 1 秒可以执行 50 个任务,给人的感觉好像计算机能同时处理多件事情,其实是 CPU 执行任务速度太快给人产生的错觉感。

3.2、实现 Runnable 接口,然后通过 Thread 类来启动介绍

/**
 * 实现 Runnable 接口
 */
public class Thread2 implements Runnable{

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
            System.out.println(time + " 当前线程:" + Thread.currentThread().getName() + ",正在运行");
        }
    }
}
/**
 * 创建一个测试类
 */
public class ThreadTest2 {

    public static void main(String[] args) {
        // 通过一个Thread来启动线程
        Thread thread2 = new Thread(new Thread2());
        thread2.start();

        for (int i = 0; i < 5; i++) {
            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
            System.out.println(time + " 当前线程:" + Thread.currentThread().getName() + ",正在运行");
        }
    }
}

输出结果:

2023-08-23 18:30:28:664 当前线程:Thread-0,正在运行
2023-08-23 18:30:28:666 当前线程:Thread-0,正在运行
2023-08-23 18:30:28:666 当前线程:Thread-0,正在运行
2023-08-23 18:30:28:664 当前线程:main,正在运行
2023-08-23 18:30:28:666 当前线程:Thread-0,正在运行
2023-08-23 18:30:28:667 当前线程:Thread-0,正在运行
2023-08-23 18:30:28:668 当前线程:main,正在运行
2023-08-23 18:30:28:668 当前线程:main,正在运行
2023-08-23 18:30:28:668 当前线程:main,正在运行
2023-08-23 18:30:28:668 当前线程:main,正在运行

效果跟上面介绍的一样,如果循环的打印次数越多,效果越明显!

四、线程状态

下图是一张从操作系统角度划分的线程模型状态!

线程被分为五种状态,各个状态说明如下:

  • 1.
    新建状态
    :表示创建了一个新的线程对象,例如
    Thread thread = new Thread()
  • 2.
    就绪状态
    :比如调用线程的
    start()
    方法,就会处于就绪状态,也被称为可执行状态,随时可能被 CPU 调度执行
  • 3.
    运行状态
    :获得了 CPU 时间片,执行程序代码。需要注意的是,
    线程只能从就绪状态进入到运行状态
  • 4.
    阻塞状态
    :因为某种原因出现了阻塞,线程放弃对 CPU 的使用权,停止执行,直到阻塞事件结束,重新进入就绪状态才有可能再次被 CPU 调度。
  • 5.
    结束状态
    :线程里面的方法正常执行结束或者因为某种异常退出了,则该线程结束生命周期

针对操作系统的线程模型,Java 进行部分封装和扩充,JVM 中的线程状态总共有六种,它们之间的关系,可以用如下图来表示:

各个状态说明如下:

  • 1.
    新建状态
    (NEW):新创建了一个线程对象
  • 2.运行状态(RUNNABLE):Java 线程中将就绪状态和运行中两种状态,笼统的称为“
    运行
    ”。线程对象创建后,调用了该对象的
    start()
    方法,该线程处于就绪状态,获得 CPU 时间片后变为运行中状态
  • 3.
    阻塞状态
    (BLOCKED):因为某种原因,线程放弃对 CPU 的使用权,停止执行,直到进入就绪状态才有可能再次被 CPU 调度。比如线程在获得
    synchronized
    同步锁失败后,会把线程放入锁池中,线程进入同步阻塞状态。
  • 4.
    等待状态
    (WAITING):处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。比如运行状态的线程执行
    wait
    方法,会把线程放在等待队列中,直到被唤醒或者因异常自动退出
  • 5.
    超时等待状态
    (TIMED_WAITING):处于这种状态的线程不会被分配 CPU 执行时间,不过无须无限期等待被其他线程显式地唤醒,在到达一定时间后它们会自动唤醒。比如运行状态的线程执行
    Thread.sleep(1000)
    方法,当到达目标时间后,会自动唤醒或者因异常自动退出
  • 6.
    终止状态
    (TERMINATED):表示该线程已经执行完毕,处于终止状态的线程不具备继续运行的能力

五、小结

本文主要围绕进程和线程的一些基础知识,进行简单的入门知识总结。

线程的特征和进程差不多,进程有的它基本都有。

相对于进程而言,线程更加的轻量化,主要承担任务的执行工作,优点如下:

  • 一个进程中可以同时拥有多个线程,这些线程共享该进程的资源。我们知道进程间的通信必须请求操作系统服务(因为 CPU 要切换到内核态),开销很大。而同进程下的线程间通信,无需操作系统干预,开销更小
  • 线程间可以并发执行任务,线程间的并发比进程的开销更小,系统并发性更好
  • 在多 CPU 环境下,各个线程也可以分派到不同的 CPU 上并行执行
  • 通过多线程编程,可以显著的提升程序任务的执行效率

不过线程也有缺点:

  • 当程序编程不合理,多个线程发生较长时间的等待或资源竞争时,可能会出现死锁
  • 等候使用共享资源时可能会造成程序的运行速度变慢。这些共享资源主要是独占性的资源,如打印机、IO 设备等

总的来说,进程和线程各有各优势,站在操作系统的设计角度而言,可以归结为以下几点:

  • 采用多进程方式,可以保证多个任务同时运行;
  • 采用多线程方式,可以将单个任务分成不同的部分进行执行;
  • 提供协调机制,防止进程之间和线程之间产生冲突,同时允许进程之间和线程之间共享资源,以充分的利用系统资源

整篇内容难免有描述不对的地方,欢迎网友留言指出!

六、参考

1、
飞天小牛肉 - 五分钟扫盲:进程与线程基础必知

2、
潘建南 - Java线程的6种状态及切换