[原创]Java并发编程(4):任务之间的协作
多个任务同时访问一个资源时,通过使用互斥量(mutex)来同步多个任务的行为,使得同一时刻只有一个任务访问资源,这解决了共享资源的问题。那么多个任务的协作问题如何解决呢?就像是面包房里的工作,必须先和面,然后饧面,然后烤面包,而在烤面包的同时还可以再和面,为下一次烘烤做准备。在多任务协作的时候,有的任务必须顺序进行,有的任务却可以同时进行,有的任务得等到多个并行进行的任务都完成之后,才可以开始。
wait和notifyAll/notify
wait()会挂起当前的任务,等待外部的任务调用notifyAll/notify来通知自己继续执行。wait()和notifyAll/notify方法有下面的特点:
- Obj.wait(),与Obj.notify()必须要与synchronized(Obj)一起使用,也就是wait,与notify是针对已经获取了Obj锁进行操作,从语法角度来说就是Obj.wait(),Obj.notify必须在synchronized(Obj){...}语句块内
- wait()调用会释放当前持有的锁,当前线程进入Blocked状态,这个Blocked状态只有notify(),notifyAll(),超时,中断,这四种情况唤醒。
- wait()调用会被notify()唤醒。需要注意的是notify()调用后,并不是马上就释放对象锁的,而是在相应的synchronized(){}语句块执行结束,自动释放锁后,JVM会在wait()对象锁的线程中随机选取一线程,赋予其对象锁,唤醒线程,继续执行。
- wait()带一个时间参数的话,就不依赖notify()来唤醒自己,在超时时,当前线程醒来,这时候,它得等待锁,如果锁可用,那么线程就可以继续,否则当前线程被阻塞,直到锁可用才能继续。(在wait()其间,即使锁是可用的,线程自己也无法打破wait(),必须等待notify()来通知自己)。使用带时间参数的wait()函数可以避免一些情况下的死锁。
wait()调用通常用在一个while循环里。需要在每次醒来后,判断所等待的状态值是否是期望的值,因为可能有很多其它线程都会通知wait()线程醒来,有些通知线程并不是要通知你的,而你醒来了,这时候,要判断状态值是否正确,如果不正确,你还得继续等待,像下面的代码一样:
public synchronized void waitForWaxing() throws InterruptedException { while (waxOn == false){ wait(1000); } //do work }
下面的例子演示了wait(),notify()的使用。例子中模拟的场景是对汽车做保养的流程:打蜡和抛光。对一辆汽车做保养的时候,可能需要打多层蜡,每打一层蜡都需要抛光,打下一层蜡之前需要需要等待抛光完成。打蜡和抛光两个工序之间需要顺序进行,在一个工序正在进行的时候,下一个工序需要等待。下图演示了这个过程:
例子:抛光和打蜡过程

import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; class Car { private boolean waxOn = false; //是否打蜡完成 public synchronized void waxed() { waxOn = true; // Ready to buff try { TimeUnit.MILLISECONDS.sleep(2000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } notifyAll(); } public synchronized void buffed() { waxOn = false; // Ready for another coat of wax notifyAll(); } public static String getMilliSecond() { return new SimpleDateFormat("yyyy/MM/dd HH:mm:ss-SSS").format(new Date()); } public synchronized void waitForWaxing() throws InterruptedException { while (waxOn == false){ //TimeUnit.SECONDS.sleep(5); System.out.println(getMilliSecond() + Thread.currentThread() + "等待打蜡中"); wait(1000); } } public synchronized void waitForBuffing() throws InterruptedException { while (waxOn == true) { //TimeUnit.SECONDS.sleep(5); System.out.println(getMilliSecond() + Thread.currentThread() + "等待抛光中"); wait(); } } } class WaxOn implements Runnable { private Car car; public WaxOn(Car c) { car = c; } public void run() { try { while (!Thread.interrupted()) { System.out.println(Car.getMilliSecond() + Thread.currentThread() + "打蜡过程。。。。 "); TimeUnit.MILLISECONDS.sleep(2000); car.waxed(); //打蜡 System.out.println(Car.getMilliSecond() + Thread.currentThread() + "打蜡完成 "); TimeUnit.MILLISECONDS.sleep(3000); System.out.println(Car.getMilliSecond() + Thread.currentThread() + "打蜡睡眠完成"); car.waitForBuffing(); //等待抛光完成,继续打下一层蜡 } } catch (InterruptedException e) { System.out.println(Car.getMilliSecond() + Thread.currentThread() + "打蜡过程被中断。。。。"); } System.out.println(Car.getMilliSecond() + Thread.currentThread() + "打蜡过程结束"); } } class WaxOff implements Runnable { private Car car; public WaxOff(Car c) { car = c; } public void run() { try { while (!Thread.interrupted()) { car.waitForWaxing(); //等待打蜡完成 System.out.println(Car.getMilliSecond() + Thread.currentThread() + "抛光过程中。。。。 "); TimeUnit.MILLISECONDS.sleep(200); car.buffed(); //抛光 System.out.println(Car.getMilliSecond() + Thread.currentThread() + "抛光完成 "); } } catch (InterruptedException e) { System.out.println(Car.getMilliSecond() + Thread.currentThread() + "抛光过程被中断。。。。"); } System.out.println(Car.getMilliSecond() + "结束抛光过程"); } } /** * 打蜡和抛光: * 1. 抛光线程需要等待打蜡线程完成才能干活 * 2. 打蜡线程需要等待抛光线程完成才能打另一层蜡 * @author Administrator * */ class WaxOMatic { public static void main(String[] args) throws Exception { Car car = new Car(); ExecutorService exec = Executors.newCachedThreadPool(); exec.execute(new WaxOff(car)); TimeUnit.MILLISECONDS.sleep(100); exec.execute(new WaxOn(car)); TimeUnit.MILLISECONDS.sleep(6000); // Run for a while... exec.shutdownNow(); // Interrupt all tasks } }
输出:

2013/02/23 13:31:55-979Thread[pool-1-thread-1,5,main]等待打蜡中 2013/02/23 13:31:56-041Thread[pool-1-thread-2,5,main]打蜡过程。。。。 2013/02/23 13:31:56-980Thread[pool-1-thread-1,5,main]等待打蜡中 2013/02/23 13:31:57-981Thread[pool-1-thread-1,5,main]等待打蜡中 2013/02/23 13:32:00-041Thread[pool-1-thread-2,5,main]打蜡完成 2013/02/23 13:32:00-041Thread[pool-1-thread-1,5,main]抛光过程中。。。。 2013/02/23 13:32:00-241Thread[pool-1-thread-1,5,main]抛光完成 2013/02/23 13:32:00-241Thread[pool-1-thread-1,5,main]等待打蜡中 2013/02/23 13:32:01-241Thread[pool-1-thread-1,5,main]等待打蜡中 2013/02/23 13:32:02-040Thread[pool-1-thread-2,5,main]打蜡过程被中断。。。。 2013/02/23 13:32:02-040Thread[pool-1-thread-1,5,main]抛光过程被中断。。。。 2013/02/23 13:32:02-040Thread[pool-1-thread-2,5,main]打蜡过程结束 2013/02/23 13:32:02-040结束抛光过程
说明:
WaxOn代表打蜡任务,WaxOff代表抛光任务,两个任务运行的时候,操作同一个Car对象。程序一共执行4000毫秒,为了确保抛光过程一定能先执行,在WaxOff任务执行之后,主线程睡眠100毫秒之后再执行WaxOn任务。WaxOff任务执行的时候,先调用waitForWaxing等待打蜡过程完成。下面是等待打蜡的函数:
public synchronized void waitForWaxing() throws InterruptedException { while (waxOn == false){ System.out.println(getMilliSecond() + Thread.currentThread() + "等待打蜡中"); wait(1000); } }
需要注意的是waitForWaxing函数会先获取当前对象的锁,它循环判断waxOn的值,如果waxOn为false,则调用wait()方法,挂起当前线程。这里wait()方法有一个超时1000毫秒的参数。从输出中可以看到,wait()方法之后,打蜡过程立即执行。接着,1000毫秒之后,waitForWaxing任务自己醒来了,这时候WaxOn任务仍然在睡眠,如下面打蜡任务(WaxOn)的主要代码所示:
while (!Thread.interrupted()) { System.out.println(Car.getMilliSecond() + Thread.currentThread() + "打蜡过程。。。。 "); TimeUnit.MILLISECONDS.sleep(2000); car.waxed(); //打蜡 System.out.println(Car.getMilliSecond() + Thread.currentThread() + "打蜡完成 "); TimeUnit.MILLISECONDS.sleep(3000); System.out.println(Car.getMilliSecond() + Thread.currentThread() + "打蜡睡眠完成"); car.waitForBuffing(); //等待抛光完成,继续打下一层蜡 }
waiForWaxing任务得以继续执行。执行完car.waxed()之后,释放了car对象的锁,waitForWaxing醒来之后,发现waxOn变量的值仍然是false,又继续睡眠,直到打蜡过程完成之后,抛光过程才继续。
在执行了6000毫秒之后,主线程调用exec.shutdownNow();向所有开始的任务发送Interrupte信号,这时候WaxOn和WaxOff任务都收到了InterruptedException异常,可能两个线程一个处于sleep()状态,一个处于wait状态吧。如果不是出于阻塞状态,线程是收不到InterruptedException异常的。
信号丢失
假设有两个任务,T1和T2,T2会判断一个条件变量,如果该变量不满足条件,T2就wait(),T1负责改变条件变量,然后notify T2,T2就会从wait()中醒来。但是如果T1先改变条件,T2后睡眠,那么T1给T2发送的notify通知就会丢失。如下面的代码:
T1: synchronized(sharedMonitor) { <setup condition for T2> sharedMonitor.notify(); } T2: while(someCondition) { // Point 1 synchronized(sharedMonitor) { sharedMonitor.wait(); } }
T2任务如果按下面的写法,就不会有这种问题:
synchronized(sharedMonitor) { while(someCondition) sharedMonitor.wait(); }
notify还是notifyAll
相比于notifyAll,使用notify是一种优化措施,在使用Notify之前需要考虑下面的问题,只有这些条件都满足的时候才能使用:
- 开发者(你)必须清楚的知道,你唤醒的线程就是你需要唤醒的。
- 将要唤醒的wait()线程必须是正在等待的条件变量和当前线程所改变的是同一个的条件变量。就像前面”打蜡,抛光”例子中的waxOn变量。
下面的例子演示了wait(),notify()和notifyAll函数的用法:
例子:wait(),notify()和notifyAll函数的用法

import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; class Blocker { synchronized void waitingCall() { try { while (!Thread.interrupted()) { System.out.println("Blocker " + Thread.currentThread() + ":wait"); wait(); System.out.println("Blocker " + Thread.currentThread() + ":wait finished"); } } catch (InterruptedException e) { System.out.println("Blocker " + Thread.currentThread() + ": interrupted"); } } synchronized void prod() { notify(); } synchronized void prodAll() { notifyAll(); } } class Task implements Runnable { static Blocker blocker = new Blocker(); public void run() { System.out.println("Task " + Thread.currentThread() + ":wait"); blocker.waitingCall(); System.out.println("Task " + Thread.currentThread() + ":wait finished"); } } class Task2 implements Runnable { // A separate Blocker object: static Blocker blocker = new Blocker(); public void run() { System.out.println("Task2 " + Thread.currentThread() + ":wait"); blocker.waitingCall(); System.out.println("Task2 " + Thread.currentThread() + ":wait finished"); } } public class NotifyVsNotifyAll { public static void main(String[] args) throws Exception { ExecutorService exec = Executors.newCachedThreadPool(); for (int i = 0; i < 5; i++) exec.execute(new Task()); exec.execute(new Task2()); Timer timer = new Timer(); timer.scheduleAtFixedRate(new TimerTask() { boolean prod = true; public void run() { if (prod) { System.out.println("\nnotify() "); Task.blocker.prod(); prod = false; } else { System.out.println("\nnotifyAll() "); Task.blocker.prodAll(); prod = true; } } }, 400, 400); // Run every .4 second TimeUnit.SECONDS.sleep(1); // Run for a while... timer.cancel(); System.out.println("\nTimer canceled"); TimeUnit.MILLISECONDS.sleep(500); System.out.println("Task2.blocker.prodAll() "); Task2.blocker.prodAll(); TimeUnit.MILLISECONDS.sleep(500); System.out.println("\nShutting down"); exec.shutdownNow(); // Interrupt all tasks } }
输出:

Task Thread[pool-1-thread-2,5,main]:wait Blocker Thread[pool-1-thread-2,5,main]:wait Task Thread[pool-1-thread-4,5,main]:wait Blocker Thread[pool-1-thread-4,5,main]:wait Task Thread[pool-1-thread-5,5,main]:wait Blocker Thread[pool-1-thread-5,5,main]:wait Task Thread[pool-1-thread-1,5,main]:wait Blocker Thread[pool-1-thread-1,5,main]:wait Task Thread[pool-1-thread-3,5,main]:wait Blocker Thread[pool-1-thread-3,5,main]:wait Task2 Thread[pool-1-thread-6,5,main]:wait Blocker Thread[pool-1-thread-6,5,main]:wait notify() Blocker Thread[pool-1-thread-2,5,main]:wait finished Blocker Thread[pool-1-thread-2,5,main]:wait notifyAll() Blocker Thread[pool-1-thread-2,5,main]:wait finished Blocker Thread[pool-1-thread-2,5,main]:wait Blocker Thread[pool-1-thread-3,5,main]:wait finished Blocker Thread[pool-1-thread-3,5,main]:wait Blocker Thread[pool-1-thread-1,5,main]:wait finished Blocker Thread[pool-1-thread-1,5,main]:wait Blocker Thread[pool-1-thread-5,5,main]:wait finished Blocker Thread[pool-1-thread-5,5,main]:wait Blocker Thread[pool-1-thread-4,5,main]:wait finished Blocker Thread[pool-1-thread-4,5,main]:wait Timer canceled Task2.blocker.prodAll() Blocker Thread[pool-1-thread-6,5,main]:wait finished Blocker Thread[pool-1-thread-6,5,main]:wait Shutting down Blocker Thread[pool-1-thread-4,5,main]: interrupted Blocker Thread[pool-1-thread-6,5,main]: interrupted Task2 Thread[pool-1-thread-6,5,main]:wait finished Task Thread[pool-1-thread-4,5,main]:wait finished Blocker Thread[pool-1-thread-2,5,main]: interrupted Task Thread[pool-1-thread-2,5,main]:wait finished Blocker Thread[pool-1-thread-3,5,main]: interrupted Task Thread[pool-1-thread-3,5,main]:wait finished Blocker Thread[pool-1-thread-5,5,main]: interrupted Task Thread[pool-1-thread-5,5,main]:wait finished Blocker Thread[pool-1-thread-1,5,main]: interrupted Task Thread[pool-1-thread-1,5,main]:wait finished
说明:
例子中定义了2个任务类,Task和Task2,两者都有一个static类型的Blocker对象,两个任务执行的时候会调用Blocker对象的synchronized void waitingCall方法,该方法是受Blocker对象锁保护的,同一时刻只能有一个任务进入这个方法。waitingCall方法内部会调用wait(),这样就释放了进入该方法的线程持有的Blocker对象锁,其它线程就能够进入该方法。从输出结果中可以看出这一点。5个执行的Task任务依次进入了waitingCall方法。
main()函数中,依次调用notify()和notifyAll()方法,第一次调用notify()方法时,随机唤醒了一个wait()线程(这里是线程2:Thread[pool-1-thread-2,5,main])。因为while(!Thread.interrupted())循环,被唤醒的线程立即再次进入wait()状态。调用notifyAll的时候,所有wait()线程都被唤醒了,它们都立即再次进入wait。要注意的是,Task2任务一直都没有被唤醒,因为它和Task任务使用的锁不是同一个对象。直到调用Task2.blocker.prodAll(),才唤醒了Task2任务。最后exec.shutdownNow()向所有运行中的任务发起interrupt()中断信号。各任务通过判断线程的interrupted状态,平稳退出。
显示使用Lock和Condition对象
Lock和condition对象,以及signal()和signalAll()方法是synchronized关键字和notify/notifyAll的一种替代方案,因为它们用起来更复杂一些,所以一般情况下不会使用,只有用它们解决一些需要特别控制的问题时,才会用到。
阻塞队列
使用wait和notifyAll函数来解决多任务的协作问题,是一种比较底层的方法,我们需要手工处理每一个交互。使用“阻塞队列”可以在比较高的层次来解决多任务协作的问题。在同一时刻,“阻塞队列”只允许一个任务向队列中插入或移除元素。阻塞队列都实现了java.util.concurrent.BlockingQueue接口。有下面三种实现:
- LinkedBlockingQueue
- ArrayBlockingQueue
- SynchronousQueue
三者的区别在于,LinkedBlockQueue是没有大小限制的,而ArrayBlockingQueue固定大小的。最特殊的当属SynchronousQueue,它并不是真正的队列,而是一种管理直接在线程之间移交信息的机制。SynchronousQueue的size()方法返回的永远是0,也就是它永远是空的。可以用“一手交钱,一手交货”来形容它的工作模式,就是说在调用put方法向SynchronousQueue队列插入元素时,插入操作会被阻塞,直到消费者线程调用take()方法从SynchronousQueue队列中获取元素。反之,亦然。
下面的例子演示了这三种队列的使用,说明部分只解释了相对比较难理解的SynchronousQueue的使用。
例子:阻塞队列

import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.TimeUnit; class LiftOff implements Runnable { protected int countDown = 10; // Default private static int taskCount = 0; private final int id = taskCount++; public LiftOff() { } public LiftOff(int countDown) { this.countDown = countDown; } public String status() { return "#" + id + "(" + (countDown > 0 ? countDown : "Liftoff!") + "), \n"; } public void run() { while (countDown-- > 0) { System.out.print(status()); Thread.yield(); } } } import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.Date; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.TimeUnit; class LiftOffRunner implements Runnable { private BlockingQueue<LiftOff> rockets; public LiftOffRunner(BlockingQueue<LiftOff> queue) { rockets = queue; } public void add(LiftOff lo) { try { System.out.println(new Date() + " put waiting here..."); rockets.put(lo); System.out.println(new Date() + " put finished"); } catch (InterruptedException e) { System.out.println("Interrupted during put()"); } } public void run() { try { while (!Thread.interrupted()) { System.out.println(new Date() + " take waiting here..."); TimeUnit.MILLISECONDS.sleep(10000); LiftOff rocket = rockets.take(); System.out.println(new Date() + " take finished"); System.out.println(new Date() + " get one,runing"); rocket.run(); // Use this thread System.out.println(new Date() + " get is sleep..."); } } catch (InterruptedException e) { System.out.println("Waking from take()"); } System.out.println("Exiting LiftOffRunner"); } } public class TestBlockingQueues { static String getkey() { try { // Compensate for Windows/Linux difference in the // length of the result produced by the Enter key: return new BufferedReader(new InputStreamReader(System.in)).readLine(); } catch (java.io.IOException e) { throw new RuntimeException(e); } } static String getkey(String message) { System.out.println(message); return getkey(); } static void test(String msg, BlockingQueue<LiftOff> queue) { System.out.println(msg); LiftOffRunner runner = new LiftOffRunner(queue); Thread t = new Thread(runner); t.start(); for (int i = 0; i < 5; i++) { if(getkey("Press ‘add’ (" + msg + ")").equals("add")) { runner.add(new LiftOff(6)); //runner.add(new LiftOff(3)); } } getkey("Press ‘Enter’ (" + msg + ")"); t.interrupt(); System.out.println("Finished " + msg + " test"); } public static void main(String[] args) { /* test("LinkedBlockingQueue", // Unlimited size new LinkedBlockingQueue<LiftOff>()); test("ArrayBlockingQueue", // Fixed size new ArrayBlockingQueue<LiftOff>(3)); */ test("SynchronousQueue", // Size of 1 new SynchronousQueue<LiftOff>()); } }
输出:

SynchronousQueue Press ‘add’ (SynchronousQueue) Wed Feb 27 02:05:43 CST 2013 take waiting here... add Wed Feb 27 02:05:48 CST 2013 put waiting here... Wed Feb 27 02:05:53 CST 2013 take finished Wed Feb 27 02:05:53 CST 2013 put finished Press ‘add’ (SynchronousQueue) Wed Feb 27 02:05:53 CST 2013 get one,runing #0(5), #0(4), #0(3), #0(2), #0(1), #0(Liftoff!), Wed Feb 27 02:05:53 CST 2013 get is sleep... Wed Feb 27 02:05:53 CST 2013 take waiting here...
说明:
LiftOff就是存储在阻塞队列中的对象,只不过它是一个Runnable的实现类,它可以作为一个独立的线程执行。LiftOffRunner线程的add()方法向阻塞队列中插入元素,而run()方法会从阻塞队列中取出元素。main()方法中,执行test函数,它首先启动了LiftOffRunner线程,等待用户输入”add”,用户完成输入后,会调用LiftOffRunner的add方法向队列中添加元素。最后终止启动的线程。请仔细看一下输出中每一条输出的时间,take()方法睡眠10秒钟的期间,put()方法被阻塞了,直到take()睡眠完成,put()方法才得以执行。
管道
管道是另一种解决生产者-消费者线程交互问题的方式,它以I/O的方式实现任务之间的通信,它本质上也是阻塞队列,只是它出现的时间比BlockingQueue要早。管道是用Java库中的PipedReader和PipedWriter来实现的。下面的例子演示了管道的使用。
例子:管道的使用

import java.io.IOException; import java.io.PipedReader; import java.io.PipedWriter; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; class Receiver implements Runnable { private PipedReader in; public Receiver(Sender sender) throws IOException { in = new PipedReader(sender.getPipedWriter()); } public void run() { try { while (true) { // Blocks until characters are there: System.out.println("Read: " + (char) in.read() + ", "); } } catch (IOException e) { System.out.println(e + " Receiver read exception"); } } } class Sender implements Runnable { private Random rand = new Random(47); private PipedWriter out = new PipedWriter(); public PipedWriter getPipedWriter() { return out; } public void run() { try { while (true) for (char c = 'A'; c <= 'z'; c++) { out.write(c); System.out.println("write: " + c); TimeUnit.MILLISECONDS.sleep(rand.nextInt(500)); } } catch (IOException e) { System.out.println(e + " Sender write exception"); } catch (InterruptedException e) { System.out.println(e + " Sender sleep interrupted"); } } } public class PipedIO { public static void main(String[] args) throws Exception { Sender sender = new Sender(); Receiver receiver = new Receiver(sender); ExecutorService exec = Executors.newCachedThreadPool(); exec.execute(sender); exec.execute(receiver); TimeUnit.SECONDS.sleep(2); exec.shutdownNow(); } }
输出:

write: A
Read: A,
write: B
write: C
write: D
write: E
Read: B,
Read: C,
Read: D,
Read: E,
write: F
write: G
java.lang.InterruptedException: sleep interrupted Sender sleep interrupted
java.io.InterruptedIOException Receiver read exception
说明:
根据线程的调度情况不同,程序的执行结果也不同。pipeWriter每向管道写入一个字符,都会睡眠一会,这时候pipeReader是可以立即开始读取的,但是从结果上看,并不是这样,pipeReader并不是立即被调度。管道背后的机制,我们暂且不去考虑,单从这个例子的执行结果上看,pipeWriter和pipeReader之间的调度并不是很高效。
posted on 2013-03-02 01:07 seeker2012 阅读(295) 评论(0) 编辑 收藏 举报
【推荐】还在用 ECharts 开发大屏?试试这款永久免费的开源 BI 工具!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步