现象

在线推理服务上线的过程中出现pf4j循环依赖的问题,同时有两个定时任务执行了插件加载的逻辑,并且都出现了org.pf4j.DependencyResolver$CyclicDependencyException: Cyclic dependencies

pf4j01
pf4j01

现场处理方式:重启机器,算法包加载正常,推理流量执行正常

问题:pf4j中的dependencies指的是在plugin.properties中配置的依赖的其他插件,判责引擎算法包中并没有配置,为什么会出现循环依赖?

pf4j02
pf4j02

问题排查

1、对插件操作加锁存在并发问题,可能同时加载多个算法包(或是对一个算法包重复加载)

pf4j03
pf4j03

非原子性操作

pf4j04
pf4j04

2、pf4j是线程不安全的

在分析代码之前,我们先看下pf4j在解析依赖过程中的类图关系,neighbors(维护依赖之间关系的map)是Resolver的一个成员变量

pf4j05
pf4j05

很显然,问题是在加载插件的时候出现的,先看一下pf4j加载插件的代码
org.pf4j.AbstractPluginManager#loadPlugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public String loadPlugin(Path pluginPath) {
if ((pluginPath == null) || Files.notExists(pluginPath)) {
throw new IllegalArgumentException(String.format("Specified plugin %s does not exist!", pluginPath));
}

log.debug("Loading plugin from '{}'", pluginPath);

// 加载、读取插件配置文件等,构建pluginWrapper
PluginWrapper pluginWrapper = loadPluginFromPath(pluginPath);

// 解析、加载插件的依赖
resolvePlugins();

return pluginWrapper.getDescriptor().getPluginId();
}

我们主要看下这一步resolvePlugins();
org.pf4j.AbstractPluginManager#resolvePlugins

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected void resolvePlugins() {
// retrieves the plugins descriptors
List<PluginDescriptor> descriptors = new ArrayList<>();
for (PluginWrapper plugin : plugins.values()) {
descriptors.add(plugin.getDescriptor());
}

// 这行代码是核心,后面会分析到
DependencyResolver.Result result = dependencyResolver.resolve(descriptors);

// 这个异常是不是很眼熟了!全局唯一出现的地方
if (result.hasCyclicDependency()) {
throw new DependencyResolver.CyclicDependencyException();
}

// ......
}

紧接着看下什么情况会出现cyclicDependency为true
org.pf4j.DependencyResolver.Result#Result

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean hasCyclicDependency() {
return cyclicDependency;
}

Result(List<String> sortedPlugins) {
if (sortedPlugins == null) {
cyclicDependency = true;
this.sortedPlugins = Collections.emptyList();
} else {
this.sortedPlugins = new ArrayList<>(sortedPlugins);
}

notFoundDependencies = new ArrayList<>();
wrongVersionDependencies = new ArrayList<>();
}

问题转换为寻找sortedPlugins为null情况,我们回到上面提到的这段核心代码,看看如何构造Result的
org.pf4j.AbstractPluginManager#resolvePlugins

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
DependencyResolver.Result result = dependencyResolver.resolve(descriptors);
org.pf4j.DependencyResolver#resolve
public Result resolve(List<PluginDescriptor> plugins) {
dependenciesGraph = new DirectedGraph<>();
dependentsGraph = new DirectedGraph<>();

// populate graphs
Map<String, PluginDescriptor> pluginByIds = new HashMap<>();
for (PluginDescriptor plugin : plugins) {
addPlugin(plugin);
pluginByIds.put(plugin.getPluginId(), plugin);
}

log.debug("Graph: {}", dependenciesGraph);

// get a sorted list of dependencies
List<String> sortedPlugins = dependenciesGraph.reverseTopologicalSort();
log.debug("Plugins order: {}", sortedPlugins);

// create the result object
Result result = new Result(sortedPlugins);

// ......

return result;
}

紧抓矛盾点sortedPlugins,不要跟丢了!
org.pf4j.util.DirectedGraph#reverseTopologicalSort

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public List<V> reverseTopologicalSort() {
List<V> list = topologicalSort();
if (list == null) {
return null;
}

Collections.reverse(list);

return list;
}

public List<V> topologicalSort() {
// 初始化入度
Map<V, Integer> degree = inDegree();

// 初始化0入度栈,将入度为0的压入栈
Stack<V> zeroVertices = new Stack<>(); // stack as good as any here
for (V v : degree.keySet()) {
if (degree.get(v) == 0) {
zeroVertices.push(v);
}
}

// 拓扑排序的结果
List<V> result = new ArrayList<>();

while (!zeroVertices.isEmpty()) {
// 弹出一个0入度的元素,放入排序结果中
V vertex = zeroVertices.pop();
result.add(vertex);
// 依赖当前0入度元素的入度-1
for (V neighbor : neighbors.get(vertex)) {
degree.put(neighbor, degree.get(neighbor) - 1);
// 如果已经入度为0,压入0入度栈
if (degree.get(neighbor) == 0) {
zeroVertices.push(neighbor);
}
}
}

// 找到出现null的地方了!!!
// 校验排序结果是否与插件元素图数量一致,如果不一致则为存在循环依赖
if (result.size() != neighbors.size()) {
return null;
}

return result;
}

/**
* 初始化入度,如果没有依赖其他插件为0,依赖了一个插件即为1,依此类推
*/
public Map<V, Integer> inDegree() {
Map<V, Integer> result = new HashMap<>();
for (V vertex : neighbors.keySet()) {
result.put(vertex, 0); // all in-degrees are 0
}
for (V from : neighbors.keySet()) {
for (V to : neighbors.get(from)) {
result.put(to, result.get(to) + 1); // increment in-degree
}
}
return result;
}

到这一步我们已经找到了result.size() != neighbors.size()就是出现插件循环依赖的直接原因,我们这行打上多线程debug断点,尝试本地复现一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
// create the plugin manager
PluginManager pluginManager = new DefaultPluginManager();

Path path = Paths.get("/Users/wusiqi14/Documents/temp/sj_plugin_framework/sj_algorithm_algorithm_deploy_demo-1.0.8-20231026.085014-8.zip");

Thread thread = new Thread(() -> pluginManager.loadPlugin(path));
thread.setName("wsq01");

Thread thread1 = new Thread(() -> pluginManager.loadPlugin(path));
thread1.setName("wsq02");

thread.start();
thread1.start();
}

多次运行代码后出现了预期的异常:

pf4j06
pf4j06

可以看到,neighbors出现了并发问题,size为2但对应的entry只有一个

pf4j07
pf4j07

neighbors具体的实现是HashMap,在并发场景是有机会出现问题的

1
private Map<V, List<V>> neighbors = new HashMap<>();

在本地测试过程中还出现了其他并发问题:

pf4j08
pf4j08

unresolvedPlugins是AbstractPluginManager下的成员变量,对应的实现是线程不安全的ArrayList

问题修复

  • 对插件管理对象pluginManager上全局锁
  • 同时考虑到插件加载、回滚、删除三个定时任务,会出现并发获取锁失败的问题,所以可以把这3个操作放在一个定时任务里面处理

总结

  1. pf4j是线程不安全的,涉及插件的操作都需要考虑这个问题
  2. 加锁过程需要是原子性的
  3. 目前对单个plugin加锁的方案是有问题的,因为DefaultPluginManager是所有插件共用的,即使修复上面对单个插件加锁的原子性,当面对一个服务部署多个算法包的时候,还是会出现并发的问题

附录

拓扑排序:https://zhuanlan.zhihu.com/p/135094687