推荐看点
日志记录、埋点是较为常见的生产者-消费者模式使用场景,应用的主线程以及其他业务子线程作为生产者,在需要的时候向共享队列中插入日志或者埋点数据,消费者子线程则会从队列中将日志或者埋点信息取出,并进行相关业务处理,比如落盘、上云等。这样所有的日志、埋点处理均在子线程中完成,不占用主线程执行时间。并且业务独立,可以通过独立的团队进行开发,其他业务团队只需要向队列中插入数据即可。
生产者-消费者模式简介
生产者-消费者模式是一种经典的多线程设计模式,它为多线程间的协同提供了良好的解决方案。
在生产者-消费者模式中,通常有两类线程,即若干个生产者线程和若干个消费者线程。生产者线程负责提交用户请求,消费线程负责具体处理生产者提交的任务。生产者和消费者直接则通过共享内存缓冲区进行通信。
从图中可以得看出生产者-消费者模式的如下优点
-
性能提升:将一个耗时的流程拆成生产和消费两个阶段。
-
业务解耦:共享内存缓冲区作为生产者线程和消费者线程间的通信桥梁,避免了生产者线程与消费者线程直接通信,从而将两类线程进行解耦。也就是说生产者不需要知道消费者的存在,反之消费者也不需要知道生产者的存在。
-
性能平衡:共享内存缓冲区的设计也可以很好的解决生产者与消费者线程间的性能上的差异,可以通过调整慢的一方的并发数,从而提高任务的整体处理速度。
生产者-消费者模式的实现
在ArkTS上由于没有java的wait/notify机制(线程挂起并释放锁,线程唤醒),因此在ArkTS上只能通过同步队列的方式实现生产者-消费者模式。
同步队列方案中,我们需要构建一个队列来作为共享缓冲区,生产者线程通过执行offer操作,向缓冲区中添加数据;消费者线程从通过poll操作从缓冲区取出数据。
在实现生产者-消费者模式之前,我们需要先完成内存缓存区的开发。
共享内存缓冲区的实现
共享内存缓冲需要被多个线程使用(offer、poll),因此需要使用Sendable实现、。在生产者-消费者模式中,缓冲区采用FIFO的方式进行队列管理,又由于多个线程都需要对队列进行操作,因此对于队列的操作必须是线程安全的。
基于以上思想,我们需要先实现节点对象ShareNode
,由于需要在多个线程间共享,所以他必须是Sendble类型变量。其中item
表示节点的指,next
为指向下一个节点的指针。
@Sendable
export class ShareNode<E> {
item : E | null = null;
next : ShareNode<E> | null = null
}
接下来就可以实现队列类ShareQueue了,由于需要在多个线程间共享,所以他必须是Sendble类型变量。head为队首元素,初始值为null,当插入第一个元素时为其赋值,随着队首元素的移出修改指向的对象,具体参考poll方法的实现tail为对位元素,初始值为null,当插入第一个元素时为其赋值,并随着新元素的插入修改指向的对象,具体参考offer方法的实现。
import { ShareNode } from './ShareNode';
import utils from '@arkts.utils';
import { ArkTSUtils} from '@kit.ArkTS';
@Sendable
export class ShareQueue<E> {
private head : ShareNode<E> | null = null
private tail : ShareNode<E> | null = null
constructor() {
}
public async offer(e : E) : Promise<boolean> {
let lock: utils.locks.AsyncLock = utils.locks.AsyncLock.request('ShareQueue');
return lock.lockAsync(() => {
let node = new ShareNode<E>();
node.item = e;
if (this.tail != null) {
this.tail.next = node;
this.tail = node
return true;
} else {
// 首元素加入队列时,队首和队尾元素都指向首元素
this.head = node;
this.tail = node;
return true;
}
}, ArkTSUtils.locks.AsyncLockMode.EXCLUSIVE)
}
public async poll() : Promise<E|null> {
let lock: utils.locks.AsyncLock = utils.locks.AsyncLock.request('ShareQueue');
return lock.lockAsync(() => {
let p : ShareNode<E> | null = this.head;
if (p != null) {
let item : E | null = p.item;
this.head = p.next;
// 当队列全部取出时,将队尾元素一同置空。
if (this.head == null) {
this.tail = null
}
return item;
} else {
return null;
}
}, ArkTSUtils.locks.AsyncLockMode.EXCLUSIVE)
}
代码中使用了AsyncLock对临界区进行加锁,offer和poll方法的实现也不复杂。
-
当调用offer方法新增元素节点时,需要新建一个ShareNode对象,并将当前队尾元素tail的next指针指向新元素,之后再将tail = node指向新元素,实现新元素的入队。
-
当调用poll方法获取队首元素节点时,需要先将队首元素head赋值给中间变量p,并将p的next属性指向的元素(即第二个元素)的指针赋值给head = p.next,并返回原先的队首元素,从而实现了队首元素的向后移动。
从代码可以看出,此处构建的ShareQueue是一个无限长的队列。
消费者线程实现
结合业务场景,消费者需要不断的从队列中获取业务数据(日志信息、埋点信息),因此消费者线程应该是一个长期存在的线程,它贯穿于整个应用的生命周期,因此消费者线程可以选择Worker或者LongTask来实现(案例采用LongTask实现)。以下为子线程执行业务逻辑代码:
@Concurrent
export async function consumerTask(taskName : string, sq : ShareQueue<LogInfo>, cc : LongTaskController) {
let unused = ArkTSUtils.locks.AsyncLock;
while (true) {
if (cc.command == LongTaskCommandEnum.STOP) {
break;
}
let start : number;
start = Date.now();
while (Date.now() - start < 100) {
// 模拟等待0.1秒
}
let logInfo = await sq.poll()
if (logInfo != null) {
taskpool.Task.sendData(logInfo);
}
}
}
其中LogInfo是从队列里面拿到的日志信息,并在任务中做了0.1秒的模拟等待,当从ShareQueue中获取到日志信息后,将日志发送给主线程刷新UI。由于消费者线程是一个通过new taskpool.LongTask()构建的长时任务,且子线程一直处于while(true)的循环中,因此如果需要停止线程,需要将控制命令LongTaskController状态置为LongTaskCommandEnum.STOP,并在线程结束的promise回调中调用taskpool.terminateTask()将长时任务停掉。以下为消费者长时线程任务常见管理实现代码片段:
@Component
export struct Producer_ConsumerPage {
private consumer_one !: taskpool.LongTask ;
private sq : ShareQueue<LogInfo> = new ShareQueue<LogInfo>()
@State logInfo : string = "";
...
build() {
NavDestination() {
Column() {
...
Button('一个消费者线程消费').onClick(event => {
this.consumerController_1.command = LongTaskCommandEnum.STOP
this.consumerController_1 = new LongTaskController();
this.startComsumer("consumer_one", this.consumer_one, this.consumerController_1);
})
TextArea({text: this.logInfo}).focusable(false).height('50%').width('80%').alignSelf(ItemAlign.Center).fontSize(10)
}
}
}
private startComsumer(taskName : string, consumer : taskpool.LongTask, lct : LongTaskController) {
lct.command = LongTaskCommandEnum.START;
consumer = new taskpool.LongTask(taskName, consumerTask, taskName, this.sq, lct);
consumer.onReceiveData(async (logInfo: LogInfo) => {
let log = taskName + " : " + logInfo.logMsg + "\n";
this.logInfo = log + this.logInfo;
});
taskpool.execute(consumer).then(() => {
taskpool.terminateTask(consumer);
});
}
}
案例中还模拟实现了多个消费者线程的场景,实际业务中,可以结合共享缓冲区内的信息数量,需要的动态新增消费者线程数,以确保共享缓冲区内积压的消息数量不要过多;当消息较少时,则可以减少消费者线程,释放线程资源。
生产者线程实现
结合业务场景,生产者可能是任何线程,比如UI主线程、或者任意子线程。因此案例中提供了主线程作为生产者、普通Task作为生产者、LongTask作为生产者。主线程(UI线程)生产日志:
@Component
export struct Producer_ConsumerPage {
private sq : ShareQueue<LogInfo> = new ShareQueue<LogInfo>()
build() {
NavDestination() {
Column() {
Button('主线程生产一条日志').onClick(event => {
let logInfo : LogInfo = new LogInfo("mainThrea", LogLevelEnum.INFO, "this is a log from Producer_ConsumerPage")
this.sq.offer(logInfo);
})
...
}
.justifyContent(FlexAlign.SpaceEvenly)
.height('100%')
.width('100%')
}
}
}
子线程生产日志
@Component
export struct Producer_ConsumerPage {
private producer !: taskpool.Task;
private sq : ShareQueue<LogInfo> = new ShareQueue<LogInfo>(10)
build() {
NavDestination() {
Column() {
Button('十个子线程生产十条日志').onClick(event => {
let i = 0;
for (i = 0; i < 10; i++) {
taskpool.execute(this.producer);
}
})
...
}
}
.onAppear( () => {
this.producer = new taskpool.Task("producer", producerTask, "producer", this.sq);
})
}
@Concurrent
export async function producerTask(taskName : string, sq : ShareQueue<LogInfo>) {
let logInfo : LogInfo;
logInfo= new LogInfo(taskName, LogLevelEnum.DEBUGGER, "log from " + taskName)
await sq.offer(logInfo)
}
除此之外也可以用LongTask进行生产,此处不再赘述,可以参考消费者线程的长时任务构建和停止逻辑。