iOS 锁&线程安全

为什么要用锁?

为了保证多线程访问一块公共资源时,对资源的保护。或者说是多线程安全 or 线程同步
但是线程同步的实现并不是只有加锁才能解决,串行队列也是一种解决方式。

锁通用使用步骤
//带❀的是一定要有的步骤。 
❀初始化锁 | 赋予一定参数
❀加锁 | 通过一定条件加锁
等待 | 线程进入 wait 等待条件  
❀处理公共资源代码 { } 
❀解锁 | 给锁赋予条件
销毁锁 & 锁的属性

❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀正片❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀❀

1.OSSpinLock (Deprecated)

介绍: 是一种'自旋锁'
使用: 
#import <libkern/OSAtomic.h>
    OSSpinLock lock = OS_SPINLOCK_INIT;
    //加锁
    OSSpinLockLock(&lock);
    //尝试加锁
    BOOL lockStatus = OSSpinLockTry(&lock);
    //你需要保护的操作
    {}
    //解锁
    OSSpinLockUnlock(&lock);
#define OS_SPINLOCK_INIT    0   (就是把lock赋值为 0)
不过这个锁已经被废弃掉了。可查看.h文件中的介绍。
'OSSpinLock' is deprecated: first deprecated in iOS 10.0 - Use os_unfair_lock() from <os/lock.h> instead

废弃原因:
新版 iOS 中,系统维护了 5 个不同的线程优先级/QoS: background,utility,default,user-initiated,user-interactive。高优先级线程始终会在低优先级线程前执行,一个线程不会受到比它更低优先级线程的干扰。这种线程调度算法会产生潜在的优先级反转问题,从而破坏了 spin lock。
具体来说,如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。这并不只是理论上的问题,libobjc 已经遇到了很多次这个问题了,于是苹果的工程师停用了 OSSpinLock。

but, 除非开发者能保证访问锁的线程全部都处于同一优先级,否则 iOS 系统中所有类型的自旋锁都不能再使用了。

2.os_unfair_lock

介绍: 是一种低级锁('Low-level'),'互斥锁' ,看了好多博客说是自旋锁,其实都是错的。
os_unfair_lock虽然是  'OSSpinLock'  的替代品,但是它确实是互斥锁。
👇有对os_unfair_lock是互斥锁的考证。
.h中的官方解释 
Does not spin on contention but waits in the kernel to be woken up
by an unlock

使用方法

    #import <os/lock.h>
    //静态初始化
    os_unfair_lock lock = OS_UNFAIR_LOCK_INIT;
    //加锁
    os_unfair_lock_lock(&lock);
    bool isCanLock = os_unfair_lock_trylock(&lock);
    //解锁
    os_unfair_lock_unlock(&lock);

3.pthread_mutex_t

介绍: 是一种跨平台的锁(Linux,Unix,OS,iOS),本质上是一种 互斥锁,可以动态初始化。
根据传入的参数生成对应的锁.(e.g. 递归锁)

使用介绍

#import <pthread.h>
    //静态初始化锁
    pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;
    //动态初始化
    pthread_mutex_t mutex;
    //初始化属性
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
     //传入  PTHREAD_MUTEX_RECURSIVE  (递归锁属性。)
     //PTHREAD_MUTEX_ERRORCHECK(错误检查)
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);
    pthread_mutex_init(&mutex, NULL); (初始化属性可为null) 
    //注: #  define NULL ((void*)0)
    //动态初始化锁
    pthread_mutex_init(&mutex, &attr);
     //销毁,一定销毁对应的属性。
    pthread_mutexattr_destroy(&attr);
     pthread_mutex_destroy(&mutex);
    //加锁解锁
    pthread_mutex_lock(&mutex);
    pthread_mutex_unlock(&mutex);
// 关于另一种属性的解释 PTHREAD_MUTEX_ERRORCHECK
This type of mutex provides error checking. A thread attempting to relock 
this mutex without first unlocking it shall return with an error. A thread 
attempting to unlock a mutex which another thread has locked shall return
 with an error. A thread attempting to unlock an unlocked mutex shall 
return with an error.

4.pthread_cond_t

介绍:条件锁,是pthread_mutex_t引申出来的锁。
配合pthread_mutex_t来一起使用,可以用于线程的同步。亦或者是解决线程间的依赖关系。 
当当前线程进入 wait 之后, 当前线程 mutex 会放开,保证其他线程可以拿到锁 mutex 执行,
直到收到 signal 信号或者broadcast之后才会唤醒 当前线程,并且 唤醒后再次对 mutex 进行加锁。
    //条件锁
    pthread_cond_t cond;
    //静态初始化
    pthread_cond_t cond2 = PTHREAD_COND_INITIALIZER;
    pthread_condattr_t condAttr;
    //初始化attr参数
    pthread_condattr_init(&condAttr);
    //动态初始化,也可不传attr参数
    pthread_cond_init(&cond, &condAttr);
    pthread_cond_init(&cond, NULL);
    //1.放开当前锁 2.使当前线程进入休眠(wait) 3.唤醒后会再次mutex程加锁
    pthread_cond_wait(&cond, &mutex);
    //在time之前等待,之后放开锁。
    pthread_cond_timedwait(&cond, &mutex, const struct timespec *restrict _Nullable);
    //唤醒一个被wait的线程
    pthread_cond_signal(&cond);
    //唤醒所有被wait的线程
    pthread_cond_broadcast(&cond);
    //销毁attr 和cond
    pthread_condattr_destroy(&condAttr);
    pthread_cond_destroy(&cond);
5.pthread_rwlock_t
介绍: 读写锁,(互斥锁的进化)分为读锁(rlock)和写锁(wlock),可以有多个线程共同持有读锁,但是写锁只能有一个线程持有,如果读锁被持有是,写锁是不能持有的。
需要等待读锁unlock 才能持有写锁,同样需要写锁unlock才能持有读锁。

具体使用

//静态初始化
        pthread_rwlock_t lock = PTHREAD_RWLOCK_INITIALIZER;
        _rwlock = lock;
//动态初始化
        pthread_rwlockattr_init(&_rwlock_attr);
        pthread_rwlock_init(&_rwlock, &_rwlock_attr);
- (void)__add {
//写锁上锁
    pthread_rwlock_wrlock(&_rwlock);
    [super __add];
    pthread_rwlock_unlock(&_rwlock);
}
- (void)__readArr {
//读锁上锁
    pthread_rwlock_rdlock(&_rwlock);
    NSLog(@"self.lockArr=%@",self.lockArray);
    pthread_rwlock_unlock(&_rwlock);
}
- (void)dealloc {
//销毁 锁 & 锁的属性
    pthread_rwlockattr_destroy(&_rwlock_attr);
    pthread_rwlock_destroy(&_rwlock);
}
/*
 * Mutex type attributes
 */
#define PTHREAD_MUTEX_NORMAL        0
#define PTHREAD_MUTEX_ERRORCHECK    1
#define PTHREAD_MUTEX_RECURSIVE     2
#define PTHREAD_MUTEX_DEFAULT       PTHREAD_MUTEX_NORMAL
6.NSLock 、NSCondition 、NSConditionLock和NSRecursiveLock
简介: 都属于互斥锁。
NSLock 底层是对 pthread_mutex_t 的封装.对应的参数是 PTHREAD_MUTEX_NORMAL
NSCondition 底层则是对 pthread_cond_t 的封装. 
NSConditionLock 的底层则是使 NSCondition 实现的.
NSRecursiveLock 则是对 pthread_mutex_t 的 PTHREAD_MUTEX_RECURSIVE 参数的封装。
实现原理可以通过 GNUstep 查看 
以上都是苹果对pthread_mutex的封装,让锁的使用更面向对象了。

具体使用

    NSLock *lock = [[NSLock alloc] init];
    //尝试加锁
    BOOL isLocked = [lock tryLock];
    [lock lock];
    [lock unlock];

    //由于 NSCondition 是对 pthread_cond_t 的封装,所以使用方法与 pthread_cond_t 基本一致。
    //不同的是不需要我们去手动销毁锁。
    NSCondition *conLock = [[NSCondition alloc] init];
    [conLock lock];
    [conLock wait];
    [conLock waitUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
    [conLock unlock];
    [conLock signal];
    [conLock broadcast];

//NSConditionLock  设置condition 保证多线程中的同步,按自己想要的顺序执行。
//先add 然后 remove。
self.conditionLock = [[NSConditionLock alloc] init]; //默认condition 是0。
self.conditionLock = [[NSConditionLock alloc] initWithCondition:1];
- (void)demoTest {
    [[[NSThread alloc] initWithTarget:self selector:@selector(__remove) object:nil] start];
    sleep(3);
    [[[NSThread alloc] initWithTarget:self selector:@selector(__add) object:nil] start];
}
- (void)__remove {
    [self.conditionLock lockWhenCondition:2];
    [super __remove];
    [self.conditionLock unlock];
}
- (void)__add {
    [self.conditionLock lockWhenCondition:1];
    [super __add];
    [self.conditionLock unlockWithCondition:2];
}

// NSRecursiveLock 用法类似于 NSLock 但是可以递归加锁。
    NSRecursiveLock *recursiveLock = [[NSRecursiveLock alloc] init];
    [recursiveLock lock];
    [recursiveLock unlock];
7.dispatch_semaphore
简单来说并不是锁,而是通过信号的方式,可以实现锁的一种机制。

简单使用

//create 的value 代表最多有几个信号量
     dispatch_semaphore_t sema = dispatch_semaphore_create(0);
    dispatch_after(dispatch_time( DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC),              dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSLog(@"走到了块里");
        dispatch_semaphore_signal(sema);//发送1个信号量
    });
    NSLog(@"等待-----");
//如果信号量的值 >0,就让信号量的值减1,然后继续往下执行代码
//如果信号量的值 <= 0,就让线程 `sleep` (休眠).直到信号量 >0.
    dispatch_wait(sema, DISPATCH_TIME_FOREVER);
//  dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);  //两个方法都可
    NSLog(@"完成”);
2018-07-13 16:34:17.524572 AddressBook[6830:473890] 等待-----
2018-07-13 16:34:20.823293 AddressBook[6830:473911] 走到了块里
2018-07-13 16:34:20.823515 AddressBook[6830:473890] 完成
8.@synchronized(id obj) { }
简介: 互斥锁
关于 更深的synchronized的实现。实际上也是对  pthread_mutex 的递归锁的一个封装。

简单使用和底层实现:

 @synchronized(id obj) {
      //公共资源操作
        NSLog(@"加锁");
    }

实现原理:  调用堆栈
0x107d25111 <+2193>: callq  0x107d27b68 ; symbol stub for: objc_sync_enter
0x107d25139 <+2233>: callq  0x107d27ab4 ; symbol stub for: NSLog
0x107d2514a <+2250>: callq  0x107d27b6e ; symbol stub for: objc_sync_exit
0x107d2515c <+2268>: callq  0x107d27b44 ; symbol stub for: objc_release
通过查看 objc4-723 中 objc-sync.mm 源码,可以知道:
int objc_sync_enter(id obj)
{
 int result = OBJC_SYNC_SUCCESS;
   if (obj) {
        SyncData* data = id2data(obj, ACQUIRE);
        assert(data);
        data->mutex.lock();
    } else {
        // @synchronized(nil) does nothing
        if (DebugNilSync) {
            _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
        }
        objc_sync_nil();
    }
  return result;
}
SyncData结构体如下
typedef struct SyncData {
    struct SyncData* nextData;
    DisguisedPtr<objc_object> object;
    int32_t threadCount;  // number of THREADS using this block
    recursive_mutex_t mutex;
} SyncData;

objc_sync_enter 中 通过 synchronized 传入的对象obj 生成 data 结构体指针
然后data 在 LIST_FOR_OBJ(obj)  (static StripedMap<SyncList> sDataLists)中
取出对应的 mutex.lock. 
这个obj 就作为这个锁的key,从对应的hash表中找到对应的锁。
只要传入的obj相同,对应的锁就相同。
如果传入nil 则  // @synchronized(nil) does nothing (什么也做)

mutex 对应的就是 recursive_mutex_t。 
通过源码再往里面查看就知道 synchronized 实质就是 一把  RECURSIVE 的pthread_mutex_t (递归锁)。

lockdebug_recursive_mutex_lock(recursive_mutex_t *lock)
{
    auto& locks = ownedLocks();
    setLock(locks, lock, RECURSIVE);
}

补充 atomic (原子性) 很好的参考博客

改变setter,getter方法的实现,对方法进行加锁和解锁的操作(原子性操作)。
保证 setter和getter方法内部线程同步。底层实现是 os_unfair_lock 。
但是: 并不能保证 使用atomic修饰的属性 的线程安全。
而且性能消耗太大, 因为 setter和getter 方法调用频率太高!! 

源码实现:

objc4-723 中全局搜索atomic 发现在 objc-abi.h 文件中的 
objc_setProperty(id _Nullable self, SEL _Nonnull _cmd, ptrdiff_t offset,
                 id _Nullable newValue, BOOL atomic, signed char shouldCopy)
方法中。通过调用栈查看具体实现:
void objc_setProperty(id self, SEL _cmd, ptrdiff_t offset, id newValue, BOOL atomic, signed char shouldCopy) 
{
    bool copy = (shouldCopy && shouldCopy != MUTABLE_COPY);
    bool mutableCopy = (shouldCopy == MUTABLE_COPY);
    reallySetProperty(self, _cmd, newValue, offset, atomic, copy, mutableCopy);
}
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) {
 if (!atomic) {
        oldValue = *slot;
        *slot = newValue;
    } else {
        spinlock_t& slotlock = PropertyLocks[slot];
        slotlock.lock();
        oldValue = *slot;
        *slot = newValue;        
        slotlock.unlock();
    }
} //省略部分代码
由 reallySetProperty 方法可知,如果是atomic 则会在 set 前生成 PropertyLocks 锁。
set 值之后 解锁
对应的 getter 方法中 的实现 

id objc_getProperty(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) {
    if (offset == 0) {
        return object_getClass(self);
    }

    // Retain release world
    id *slot = (id*) ((char*)self + offset);
    if (!atomic) return *slot;
        
    // Atomic retain release world
    spinlock_t& slotlock = PropertyLocks[slot];
    slotlock.lock();
    id value = objc_retain(*slot);
    slotlock.unlock();
    
//再通过源码查看
StripedMap<spinlock_t> PropertyLocks;
slotlock是PropertyLocks通过 slot 从StripedMap 获取。
在查看 slotlock定义
using spinlock_t = mutex_tt<LOCKDEBUG>;
class mutex_tt : nocopy_t {
    os_unfair_lock mLock;
}
可见 底层是通过  os_unfair_lock 实现。
    // for performance, we (safely) issue the autorelease OUTSIDE of the spinlock.
    return objc_autoreleaseReturnValue(value);
}

几种主要锁的类别

互斥锁 sleep
是一种 low-level 的锁,相对于自旋锁来说比较低级。如果发现没有持有锁,则使线程进入sleep 状态。
自旋锁 busy-wait
相当于是一个外部死循环。当其他线程访问被锁的资源后,会一直进行循环,进入 busy-wait的状态,自旋锁不会引起调用者休眠,节省了线程休眠的状态切换,所以有更高的效率。
直到其他线程锁放开,因为线程一直在进行执行,所以会一直占用cpu资源。
递归锁
可以让当前线程递归的去给当前线程加锁,然后解锁。
比如: 
    //动态初始化
    pthread_mutex_t mutex;
    //初始化属性
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    pthread_mutex_init(&mutex, &attr);
- (void)pthread_mutex_recursive {
    //加锁
    pthread_mutex_lock(&mutex);
    for (int i=0; i<5; i++) {
//递归调用
        [self pthread_mutex_recursive];
    }
    pthread_mutex_unlock(&mutex);
}
注意点
使用任何锁都需要消耗系统资源(内存资源和CPU时间),这种资源消耗可以分为两类:
 1.建立锁所需要的资源 
 2.当线程被阻塞时所需要的资源 

同步方案的性能排序(待考证) 从高到低

  • os_unfair_lock
  • OSSpinLock (Deprecated)
  • dispatch_semaphore
  • pthread_mutex_t
  • dispatch_queue_create("串行队列", DISPATCH_QUEUE_SERIAL); (本文未做详细介绍)
  • NSLock
  • NSCondition
  • pthread_mutex( recursive)
  • NSRecursiveLock
  • NSConditionLock
  • @synchronized

为什么 @synchronized 执行效率比 NSLock效率低?

锁的粒度: @synchronized 的锁粒度更粗,它锁定的是一个对象,而不是一个具体的代码块。这意味着如果多个临界区使用了相同的对象作为锁,那么它们将会互斥,即使它们没有直接的竞争条件。相比之下,NSLock 允许更细粒度的控制,可以只锁定需要保护的临界区,从而减少了互斥的范围,提高了并发性能。

互斥锁的开销: @synchronized 在内部使用了互斥锁来实现线程同步,而 NSLock 也使用了类似的机制。然而,由于 @synchronized 是一个语言级别的特性,其实现可能比 NSLock 更复杂,并且可能会引入一些额外的开销,如锁的创建和释放等。

底层实现差异: @synchronized 的底层实现可能会依赖于运行时系统的特性,而 NSLock 则是直接调用系统提供的互斥锁机制。因此,NSLock 的实现可能更接近操作系统的底层机制,性能上可能会更高效一些。


关于 os_unfair_lock 是互斥锁的考证

os_unfair_lock的使用.png

thread_9中对资源加锁,在thread_10中对os_unfair_lock_lock()的实现进行disassembly 查看。


image.png

下面是调用栈,省略了其他步骤

->  0x10d7628c4 <+20>:  movq   0x43bd(%rip), %rdi ; OSUnfairLock._unfair_lock
->  0x10d762fb6 <+0>: jmpq   *0x217c(%rip); os_unfair_lock_lock
->  0x1128d334b <+19>: jmp    0x1128d3350; _os_unfair_lock_lock_slow
->  0x1128d33cd <+125>: callq  0x1128d3ae6 ; _os_ulock_wait
->  0x1128d3afa <+20>:  callq  0x1128d5318 ; symbol stub for: __ulock_wait
->  0x1128d5318 <+0>: jmpq   *0x1d5a(%rip);  __ulock_wait
->  0x1128ae31c <+8>:  syscall 

当调用玩 syscall的时候线程进入休眠而不是进行自旋。所以 os_unfair_lock是互斥锁
#0  0x00000001128ae31e in __ulock_wait ()

用到的资源

写在最后: 关于技术的运用,总结一句话:知识决定你的下限,但是想象力决定你的上限。熟练的运用在项目中才是我们最需要的。

可以关注 我的掘金 也可以 关注 我的简书

如果本文帮助了你,也可以赞助我一哈,O(∩_∩)O哈哈~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • 锁是一种同步机制,用于多线程环境中对资源访问的限制iOS中常见锁的性能对比图(摘自:ibireme): iOS锁的...
    LiLS阅读 1,505评论 0 6
  • 前言 iOS开发中由于各种第三方库的高度封装,对锁的使用很少,刚好之前面试中被问到的关于并发编程锁的问题,都是一知...
    喵渣渣阅读 3,683评论 0 33
  • 引用自多线程编程指南应用程序里面多个线程的存在引发了多个执行线程安全访问资源的潜在问题。两个线程同时修改同一资源有...
    Mitchell阅读 1,976评论 1 7
  • demo下载 建议一边看文章,一边看代码。 声明:关于性能的分析是基于我的测试代码来的,我也看到和网上很多测试结果...
    炸街程序猿阅读 781评论 0 2
  • 我见过牵牛花爬上窗台 白天,黑夜 喜悦,欢快 静静地等 慢慢地开 我知道,最终它会 蜂蝶自来 我见过腊梅孤立郊外 ...
    马海燕阅读 567评论 1 2