使用DelayQueue实现延迟任务

有时候,我们需要在当前时间点往后延迟一定时间,再执行任务,该怎么实现呢?

1. 延迟任务方案

延迟任务的实现方案有很多,常见的有四类:

DelayQueueRedissonMQ时间轮
原理JDK自带延迟队列,基于阻塞队列实现。基于Redis数据结构模拟JDK的DelayQueue实现一些MQ本身支持延迟消息,例如RocketMQ 而RabbitMQ则需要通过插件来实现延迟消息时间轮算法,其中Netty中有开源的实现
优点不依赖第三方服务分布式系统下可用不占用JVM内存分布式系统下可以不占用JVM内存不依赖第三方服务性能优异
缺点占用JVM内存只能单机使用依赖第三方服务依赖第三方服务只能单机使用

以上四种方案都可以解决问题,其中采用RabbitMQ实现延迟任务可参考我另一篇博客RabbitMQ安装配置,封装工具类,发送消息及监听,延迟消息

本例中我们会使用DelayQueue方案。这种方案使用成本最低,而且不依赖任何第三方服务,减少了网络交互。

但缺点也很明显,就是不能在分布式环境下使用,需要占用JVM内存,且在数据量非常大的情况下可能会有问题。。

如果项目中数据量非常大,DelayQueue不能满足业务需求,也可以替换为其它延迟队列方式,例如Redisson、MQ等

2. DelayQueue的原理

首先来看一下DelayQueue的源码:

public class DelayQueue<E extends Delayed> extends AbstractQueue<E>implements BlockingQueue<E> {private final transient ReentrantLock lock = new ReentrantLock();private final PriorityQueue<E> q = new PriorityQueue<E>();// ... 略
}

可以看到DelayQueue实现了BlockingQueue接口,是一个阻塞队列。队列就是容器,用来存储东西的。DelayQueue叫做延迟队列,其中存储的就是延迟执行的任务

我们可以看到DelayQueue的泛型定义:

DelayQueue<E extends Delayed>

这说明存入DelayQueue内部的元素必须是Delayed类型,这其实就是一个延迟任务的规范接口。来看一下:

public interface Delayed extends Comparable<Delayed> {/*** Returns the remaining delay associated with this object, in the* given time unit.** @param unit the time unit* @return the remaining delay; zero or negative values indicate* that the delay has already elapsed*/long getDelay(TimeUnit unit);
}

从源码中可以看出,Delayed类型必须具备两个方法:

  • getDelay():获取延迟任务的剩余延迟时间
  • compareTo(T t):比较两个延迟任务的延迟时间,判断执行顺序

可见,Delayed类型的延迟任务具备两个功能:获取剩余延迟时间、比较执行顺序。当然,我们可以对Delayed做实现和功能扩展,比如添加延迟任务的数据。

新建延迟任务时,放在这样的一个Delayed类型的延迟任务并设定固定的延迟时间到DelayQueue队列。DelayQueue会调用compareTo方法,根据剩余延迟时间对任务排序。剩余延迟时间越短的越靠近队首,这样就会被优先执行。

3. DelayQueue的用法

首先定义一个Delayed类型的延迟任务类,要能保持任务数据。

package com.gzdemo.delay.utils;import lombok.Data;
import lombok.extern.slf4j.Slf4j;import java.time.Duration;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
@Data
@Slf4j
public class DelayedTask<T> implements Delayed {private T data;// 数据private long deadLineTime;// 当前消息什么时候到期// Duration jdk 提供的一个可以表示指定时间单位 时间的 对象public DelayedTask(T data, Duration duration) {this.data = data;deadLineTime =System.currentTimeMillis() +duration.toMillis();}/*** DelayQueue 的take() 方法 会不断调用   本方法,获取消息的剩余时间*  1) 消息剩余时间<=0 表示消息到期*  2) >0 表示消息未到期* @param unit* @return*/@Overridepublic long getDelay(TimeUnit unit) {//log.info("getDelay方法被调用了");//return  deadLineTime-System.currentTimeMillis();return unit.convert(deadLineTime-System.currentTimeMillis(),TimeUnit.MILLISECONDS);}/*** 如果有多个消息,则 底层存储消息时会调用该方法进行排序* 返回值:*    如果>0  表示当前任务 到期时间 长,应该放入队列的后面*    如果<=0  表示当前任务 到期时间 断,应该放入队列的前面*/@Overridepublic int compareTo(Delayed other) {// 获取当前任务时间和其他任务时间的 差Long i = this.getDelay(TimeUnit.SECONDS) -other.getDelay(TimeUnit.SECONDS);return i.intValue();}
}

接下来就可以创建延迟任务,交给延迟队列保存:

import java.time.Duration;
import java.time.LocalTime;
import java.util.concurrent.DelayQueue;@Slf4j
@RestController
public class TestController {@GetMapping("/addDelayedTask")public String addDelayedTask() throws InterruptedException {DelayQueue<DelayedTask<String>> queue = new DelayQueue(); //  模拟了一个 消息队列(理解为搭建了一台RabbitMQ)// 模拟消息的生产者queue.add(new DelayedTask("任务1的数据", Duration.ofSeconds(8))); // 添加任务到队列queue.add(new DelayedTask("任务2的数据", Duration.ofSeconds(3))); // 添加任务到队列queue.add(new DelayedTask("任务3的数据", Duration.ofSeconds(5))); // 添加任务到队列// queue.add(new DelayedTask("任务3的数据")); // 添加任务到队列log.info("{} 消息放入了...", LocalTime.now());// 模拟消息的消费者while (true) {// take() 方法可以理解为  监听器 Listener , 如果队列中有要返回的任务,则返回任务,放行// 如果没有就等待DelayedTask<String> task = queue.take();log.info("{} 收到了消息:{}",LocalTime.now(),task.getData());String data = task.getData();}}
}

注意

这里我们是直接同一个线程来执行任务了。当没有任务的时候线程会被阻塞。而在实际开发中,我们会准备线程池,开启多个线程来执行队列中的任务。

测试
在这里插入图片描述

4. 项目中使用案例

工具类,这原本是一个Redis相关的业务工具类,在创建使用redis过程中,需要用到延时任务,在这里把除了延时任务外的别的内容去掉了,展示了项目中要使用DelayQueue的大概骨架。

import com.gzdemo.delay.domain.LearningRecord;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;import javax.annotation.PostConstruct;
import java.time.Duration;
import java.time.LocalTime;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.DelayQueue;@Component
@Slf4j
@RequiredArgsConstructor
public class LearningRecordCacheHandler {private DelayQueue<DelayedTask<RecordQueueData>> queue = new DelayQueue<>();// 消息的消费时机: 启动时拉取/***  @PostConstruct: 是spring 支持的一个注解*                  写到方法上,表述 当前类的构造方法执行后执行: 初始化对象后执行*/@PostConstructpublic void  init(){System.out.println("主线程...对象创建了");// 启动时开启一个子线程,主线程不会阻塞// Thread 对象 创建的线程,不支持后续扩展线程池
//        new Thread(() -> {
//               this.dealData();;
//        }).start();// jdk 8 提供了支持线程池,创建线程的方式 CompletableFutureCompletableFuture.runAsync(()->{this.dealData();;});}public void  dealData(){while (true){try {// 获取延时消息DelayedTask<RecordQueueData> task = queue.take();RecordQueueData data = task.getData();log.info("{},延时队列获取到了消息:{}", LocalTime.now(),data);} catch (InterruptedException e) {e.printStackTrace();}}}// 写入消息到队列public void writeRecord2Queue(LearningRecord record){log.info("{},延时队列放入了消息lessonId为{}",LocalTime.now(),record.getLessonId());queue.add(new DelayedTask<>(new RecordQueueData(record), Duration.ofSeconds(15)));}// 队列中的消息对象@Data@NoArgsConstructorpublic class RecordQueueData{private Integer moment;private Long lessonId;private Long sectionId;public RecordQueueData(LearningRecord record) {this.moment = record.getMoment();this.lessonId = record.getLessonId();this.sectionId = record.getSectionId();}}}

通用的DelayedTask

@Data
@Slf4j
public class DelayedTask<T> implements Delayed {private T data;// 数据private long deadLineTime;// 当前消息什么时候到期// Duration jdk 提供的一个可以表示指定时间单位 时间的 对象public DelayedTask(T data, Duration duration) {this.data = data;deadLineTime =System.currentTimeMillis() +duration.toMillis();}/*** DelayQueue 的take() 方法 会不断调用   本方法,获取消息的剩余时间*  1) 消息剩余时间<=0 表示消息到期*  2) >0 表示消息未到期* @param unit* @return*/@Overridepublic long getDelay(TimeUnit unit) {//log.info("getDelay方法被调用了");//return  deadLineTime-System.currentTimeMillis();return unit.convert(deadLineTime-System.currentTimeMillis(),TimeUnit.MILLISECONDS);}/*** 如果有多个消息,则 底层存储消息时会调用该方法进行排序* 返回值:*    如果>0  表示当前任务 到期时间 长,应该放入队列的后面*    如果<=0  表示当前任务 到期时间 断,应该放入队列的前面*/@Overridepublic int compareTo(Delayed other) {// 获取当前任务时间和其他任务时间的 差Long i = this.getDelay(TimeUnit.SECONDS) -other.getDelay(TimeUnit.SECONDS);return i.intValue();}
}

添加任务的测试controller

    @AutowiredLearningRecordCacheHandler learningRecordCacheHandler;@GetMapping("/addDelayedTask2/{lessonId}")public String addDelayedTask2(@PathVariable Long lessonId) throws InterruptedException {LearningRecord record = LearningRecord.builder().lessonId(lessonId).moment(234).sectionId(21412L).build();learningRecordCacheHandler.writeRecord2Queue(record);return "添加消息成功";}

测试
在这里插入图片描述

5. 完整的实际开发案例

解释:
1.主要使用了Redis和DelayQueue
2.用户在网站上播放视频时,视频每隔15s会上传用户当前播放的视频的进度到后台;每次更新mysql效率低,因此使用缓存,查询的时候直接查Redis;但需要将Redis中的数据持久化到mysql中(然后删掉Redis中相关数据,否则Redis中数据越来越多),这里的判断持久化时机的逻辑是,当Redis中的数据保持20秒没有变动,就说明用户在这段时间内退出视频了,就应该持久化。
3.“当Redis中数据保持20秒没有变动”,需要使用到延迟任务。Redis中相关数据改动一项,就新建一个延时任务(将数据放到任务中)放到DelayQueue中,20秒后,判断该延迟任务中的数据和Redis中对应数据是否一致。
4.延迟任务的监听应该开新线程,使用CompletableFuture或别的。
5.为了方便开发,定义了2个内部类,供Redis和DelayedTask使用。

package com.xxx.learning.utils;import com.tianji.common.utils.JsonUtils;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.domain.po.LearningRecord;
import com.tianji.learning.mapper.LearningRecordMapper;
import com.tianji.learning.service.ILearningLessonService;
import com.tianji.learning.service.ILearningRecordService;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.DelayQueue;
import java.util.function.Supplier;/*** 添加学习记录处理器工具类* 1) 处理缓存* 2)*/
@Component
@Slf4j
@RequiredArgsConstructor
public class LearningRecordCacheHandler {private final StringRedisTemplate redisTemplate;private final ILearningLessonService lessonService;private final LearningRecordMapper recordMapper;// 缓存前缀private static final String LEARNING_RECORD_CACHE_PREFIX="learning:record:";private DelayQueue<DelayedTask<RecordQueueData>> queue = new DelayQueue<>();// 消息的消费时机: 启动时拉取/***  @PostConstruct: 是spring 支持的一个注解*                  写到方法上,表述 当前类的构造方法执行后执行: 初始化对象后执行*    @PreDestroy   是spring 支持的一个注解*                   写到方法上,表述 当前类(正常关闭容器)销毁前执行该方法*/@PostConstructpublic void  init(){System.out.println("主线程...对象创建了");// 启动时开启一个子线程,主线程不会阻塞// Thread 对象 创建的线程,不支持后续扩展线程池
//        new Thread(() -> {
//               this.dealData();;
//        }).start();// jdk 8 提供了支持线程池,创建线程的方式 CompletableFutureCompletableFuture.runAsync(()->{this.dealData();;});}@PreDestroypublic void  destroy(){System.out.println("对象销毁了");}public void  dealData(){while (true){try {// 获取延时消息DelayedTask<RecordQueueData> task = queue.take();RecordQueueData data = task.getData();log.info("延时队列获取到了消息");// 获取redis 中的消息LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId());/*** 比较消息*  如果消息不一致,证明: 用户在持续播放, 抛弃消息*/if(!data.getMoment().equals(record.getMoment())){log.info("redis 中的数据和 延时消息不一致... 不处理");continue;}log.info("redis 中的数据和 延时消息一致... 处理..写入数据库");// 一致 则证明用户暂停播放, 写入数据库// 更新 recordLearningRecord newRecord = new LearningRecord();newRecord.setId(record.getId());newRecord.setMoment(record.getMoment());recordMapper.updateById(newRecord);// 更新 lessonLearningLesson lesson = new LearningLesson();lesson.setId(data.getLessonId());lesson.setLatestSectionId(data.sectionId);lesson.setLatestLearnTime(LocalDateTime.now().minusSeconds(20));lessonService.updateById(lesson);} catch (InterruptedException e) {e.printStackTrace();}}}// 写入消息到队列public void  writeRecord2Queue(LearningRecord record){log.info("延时队列放入了消息:");queue.add(new DelayedTask<>(new RecordQueueData(record),Duration.ofSeconds(20)));}//1) 写入缓存public void writeRecordCache(LearningRecord record){log.info("缓存放入了消息:");// 1.转成只有三个属性的对象RecordCacheData cacheData = new RecordCacheData(record);// 2. 转成json 字符串String jsonData = JsonUtils.toJsonStr(cacheData);// 3 拼接key  learning:record:6688String key =LEARNING_RECORD_CACHE_PREFIX+record.getLessonId();// 4 存储缓存(覆盖)redisTemplate.opsForHash().put(key,record.getSectionId().toString(),jsonData);// 5 设置超时时间redisTemplate.expire(key, Duration.ofSeconds(60));}//2) 读取缓存public LearningRecord readRecordCache(Long lessonId,Long sectionId){//1 拼接key  learning:record:6688String key =LEARNING_RECORD_CACHE_PREFIX+lessonId;// 真正的是StringObject o = redisTemplate.opsForHash().get(key, sectionId.toString());if(o==null){return null;}// {id:1,moment:30,finshsend:false}// 转成对象LearningRecord record = JsonUtils.toBean(o.toString(), LearningRecord.class);record.setLessonId(lessonId);record.setSectionId(sectionId);return record;}//3) 删除缓存public void removeRecordCache(Long lessonId,Long sectionId){//1 拼接key  learning:record:6688String key =LEARNING_RECORD_CACHE_PREFIX+lessonId;redisTemplate.opsForHash().delete(key,sectionId.toString());}// 存储缓存的对象@Data@NoArgsConstructorpublic class RecordCacheData{private Long id;private Boolean finished;private Integer moment;public RecordCacheData(LearningRecord record) {this.id = record.getId();this.finished = record.getFinished();this.moment = record.getMoment();}}// 队列中的消息对象@Data@NoArgsConstructorpublic class RecordQueueData{private Integer moment;private Long lessonId;private Long sectionId;public RecordQueueData(LearningRecord record) {this.moment = record.getMoment();this.lessonId = record.getLessonId();this.sectionId = record.getSectionId();}}}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/39564.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

实验室必备神器:PFA气体装置,精准控制每一丝气体!

PFA气体吸收装置是一种高效的气体处理设备&#xff0c;主要用于捕获、存储和转移各种气体样本&#xff0c;特别是在需要高纯度气体的应用场合中表现出色。以下是关于PFA气体吸收装置的详细介绍&#xff1a; 一、特点与优势 1. 高效吸收&#xff1a;采用先进的物理和化学吸收技术…

01:Linux的基本命令

Linux的基本命令 1、常识1.1、Linux的隐藏文件1.2、绝对路径与相对路径 2、基本命令2.1、ls2.2、cd2.3、pwd / mkdir / mv / touch / cp / rm / cat / rmdir2.4、ln2.5、man2.6、apt-get 本教程是使用的是Ubuntu14.04版本。 1、常识 1.1、Linux的隐藏文件 在Linux中&#xf…

MATLAB将两个折线图画在一个图里

界面如图 输入行数和列数&#xff0c;点击开始填入数据&#xff0c;其中第一列为x值&#xff0c;后面几列&#xff0c;每一列都是y坐标值&#xff0c;填好后点击画在同一张图里即可。点击置零就把所有数变成0&#xff0c;另外也可以选择节点样式。 .mlapp格式的文件如下 夸克…

离线运行Llama3:本地部署终极指南

4月18日&#xff0c;Meta在官方博客官宣了Llama3&#xff0c;标志着人工智能领域迈向了一个重要的飞跃。经过笔者的个人体验&#xff0c;Llama3 8B效果已经超越GPT-3.5&#xff0c;最为重要的是&#xff0c;Llama3是开源的&#xff0c;我们可以自己部署&#xff01; 本文和大家…

师傅们 ~ 2024HW一手资料

各位师傅们&#xff0c;2024HW来了&#xff01; 从2026年开始&#xff0c;随着我国对网络安全的重视&#xff0c;涉及单位不断增加&#xff0c;越来越多单位和个人都加入到HW当中。 2024HW就在眼前&#xff0c; 那么还有不了解或者还没投简历面试的朋友们&#xff0c;需要注意…

有哪些手持小风扇品牌推荐?五大手持小风扇诚意推荐!

在炎炎夏日&#xff0c;一款便携且高效的手持小风扇无疑是消暑的必备神器。为了帮助大家轻松应对酷暑&#xff0c;我们精心挑选了五大手持小风扇品牌进行诚意推荐。这些品牌不仅拥有出色的降温效果&#xff0c;更在外观设计、便携性、续航能力及操作便捷性上表现卓越。接下来&a…

互联网医院系统源码解析:如何打造智能数字药店APP?

在互联网技术飞速发展的今天&#xff0c;医疗行业也在不断与之融合&#xff0c;互联网医院系统应运而生。特别是智能数字药店APP的兴起&#xff0c;使得医疗服务变得更加便捷、高效。本文将深入解析互联网医院系统源码&#xff0c;探讨如何打造一个智能的数字药店APP。 一、互…

KICAD针对线宽布线操作

如果在刚开始没有设置好布线宽度&#xff0c;KiCad Pcbnew 在布好线后经常会需要修改布线宽度。 下面有几种常用的修改多端线宽的方法 1、快捷键修改整个网络的线宽。 按 I 键选中整条网络&#xff0c;再按 E 键&#xff0c;即可修改整网络的线宽。 2、修改多条线的…

仿论坛项目--初识Spring Boot

1. 技术准备 技术架构 • Spring Boot • Spring、Spring MVC、MyBatis • Redis、Kafka、Elasticsearch • Spring Security、Spring Actuator 开发环境 • 构建工具&#xff1a;Apache Maven • 集成开发工具&#xff1a;IntelliJ IDEA • 数据库&#xff1a;MySQL、Redi…

厉害了,Pinokio!所有AI工具,一键安装,全部免费!整合AI绘画、AI视频、AI语音...

大家好&#xff0c;我是程序员X小鹿&#xff0c;前互联网大厂程序员&#xff0c;自由职业2年&#xff0c;也一名 AIGC 爱好者&#xff0c;持续分享更多前沿的「AI 工具」和「AI副业玩法」&#xff0c;欢迎一起交流~ 去年夏天&#xff0c;写了一篇在 Mac 上部署 Stable Diffusio…

友好前端vue脚手架

企业级后台集成方案vue-element-admin-CSDN博客在哔站学习&#xff0c;老师说可以有直接的脚手架&#xff08;vue-element-admin&#xff09;立马去搜索&#xff0c;找到了这博主这篇文章 介绍 | vue-element-admin​​​​​​ 官方默认英文版&#xff1a; git clone https:/…

红队工具Finger 安装具体以步骤-示例centos

1.git clone https://github.com/EASY233/Finger.git 如果没有 yum install git 2.pip3 install -r requirements.txt 找到finger所在的文件夹 可以用find -name "Finger"进入文件中配置命令 前提要安装python yum install python-pip33.python3 Finger.py -h

使用Spring Boot实现博客管理系统

文章目录 引言第一章 Spring Boot概述1.1 什么是Spring Boot1.2 Spring Boot的主要特性 第二章 项目初始化第三章 用户管理模块3.1 创建用户实体类3.2 创建用户Repository接口3.3 实现用户Service类3.4 创建用户Controller类 第四章 博客文章管理模块4.1 创建博客文章实体类4.2…

类和对象(提高)

类和对象&#xff08;提高&#xff09; 1、定义一个类 关键字class 6 class Data1 7 { 8 //类中 默认为私有 9 private: 10 int a;//不要给类中成员 初始化 11 protected://保护 12 int b; 13 public://公共 14 int c; 15 //在类的内部 不存在权限之分 16 void showData(void)…

德国Testing Expo丨知迪科技Vehicle Bus Tool免费软件“剧透”抢先看!

今日&#xff0c;德国斯图加特汽车测试及质量监控展览会&#xff08;Automotive Testing Expo&#xff09;在斯图加特会展中心正式开幕。作为汽车测试领域专业性最强、影响力最广泛的展会之一&#xff0c;展会首日盛况空前&#xff0c;面向组件和整车的最新测试、开发和验证技术…

观测云赋能「阿里云飞天企业版」,打造全方位监控观测解决方案

近日&#xff0c;观测云成功通过了「阿里云飞天企业版」的生态集成认证测试&#xff0c;并荣获阿里云颁发的产品生态集成认证证书。作为监控观测领域的领军者&#xff0c;观测云一直专注于提供统一的数据视角&#xff0c;助力用户构建起全球范围内的端到端全链路可观测服务。此…

微观特征轮廓尺寸测量:光学3D轮廓仪、共焦显微镜与台阶仪的应用

随着科技进步&#xff0c;显微测量仪器以满足日益增长的微观尺寸测量需求而不断发展进步。多种高精度测量仪器被用于微观尺寸的测量&#xff0c;其中包括光学3D表面轮廓仪&#xff08;白光干涉仪&#xff09;、共聚焦显微镜和台阶仪。有效评估材料表面的微观结构和形貌&#xf…

CSS|01 CSS简介CSS的3种书写方式注释

CSS简介 什么是CSS CSS&#xff08;Cascading Style Sheet&#xff09;&#xff0c;层叠样式表 或者 级联样式表&#xff0c;简称样式表。CSS的作用 主要用来给 HTML网页 设置外观或者样式。CSS的语法规则 h1 {属性:属性值}注意&#xff1a;1. CSS代码是由选择器和一对括号…

解析MySQL的数据类型:理解每种类型及其应用

MySQL是一种流行的关系型数据库管理系统&#xff0c;被广泛应用于Web应用开发中。在数据库设计的过程中&#xff0c;选择合适的数据类型至关重要&#xff0c;因为它不仅影响存储效率和数据完整性&#xff0c;还影响数据库操作的性能和查询速度。本文将详细介绍MySQL支持的各种数…

基于正点原子FreeRTOS学习笔记——时间片调度实验

目录 一、时间片调度介绍 二、实验演示 1、宏修改 1.1、滴答定时器宏 1.2、调度器宏 2、实验程序 2.1.1、任务1&#xff0c;任务2不加临界区程序 2.1.2 实验现象 2.2.1、任务1&#xff0c;任务2加临界区程序 2.2.2 实验现象 一、时间片调度介绍 时间片&#xff1a;同…