iOS多线程安全-13种线程锁????

栏目: IOS · 发布时间: 5年前

内容简介:多个线程访问同一块资源的时候,很容易引发数据混乱问题。 一个大家都喜欢拿来举例子的就是正常情况下我有50张票,然后卖了50次,剩余票数应该是0,但是打印结果竟然是3,所以这里就存在了线程安全问题。出现线程安全的原因就是在同一个时间,多个线程同时读取一个值,像线程A和B同时读取了当前票数为10,等于是卖了两张票,但是总票数其实就减少了一张。

目录

  • 1、为什么要线程安全
  • 2、自旋锁和互斥锁
  • 3、锁的类型
    • 1、OSSpinLock
    • 2、os_unfair_lock
    • 3、pthread_mutex
    • 4、dispatch_semaphore
    • 5、dispatch_queue(DISPATCH_QUEUE_SERIAL)
    • 6、NSLock
    • 7、NSRecursiveLock
    • 8、NSCondition
    • 9、NSConditionLock
    • 10、@synchronized
    • 11、pthread_rwlock
    • 12、dispatch_barrier_async
    • 13、atomic
  • 4、锁的性能比较

为什么要线程安全

多个线程访问同一块资源的时候,很容易引发数据混乱问题。 一个大家都喜欢拿来举例子的就是 买票demo ,今天我使用这个案例 假设有100张票,同时开5个窗口买票,5个窗口买票,我们来看看结果

//卖票演示
- (void)ticketTest{
self.ticketsCount = 50;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);

for (NSInteger i = 0; i < 5; i++) {
dispatch_async(queue, ^{
for (int i = 0; i < 10; i++) {
[self sellingTickets];
}
});
}

}
//卖票
- (void)sellingTickets{
int oldMoney = self.ticketsCount;
sleep(.2);
oldMoney -= 1;
self.ticketsCount = oldMoney;

NSLog(@"当前剩余票数-> %d", oldMoney);
}
复制代码
iOS多线程安全-13种线程锁????

正常情况下我有50张票,然后卖了50次,剩余票数应该是0,但是打印结果竟然是3,所以这里就存在了线程安全问题。

出现线程安全的原因

iOS多线程安全-13种线程锁????

出现线程安全的原因就是在同一个时间,多个线程同时读取一个值,像线程A和B同时读取了当前票数为10,等于是卖了两张票,但是总票数其实就减少了一张。

解决方法

使用线程同步技术,按照预定的先后次序依次进行,常见的线程同步技术就是 加锁

iOS多线程安全-13种线程锁????

自旋锁和互斥锁

自旋锁(Spin lock)

自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是 否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。其作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远 高于互斥锁。虽然它的效率比互斥锁高,但是它也有些不足之处:     1、自旋锁一直占用CPU,他在未获得锁的情况下,一直运行--自旋,所以占用着CPU,如果不能在很短的时 间内获得锁,这无疑会使CPU效率降低。     2、在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁,调用有些其他函数也可能造成死锁,如 copy_to_user()、copy_from_user()、kmalloc()等。     因此我们要慎重使用自旋锁,自旋锁只有在内核可抢占式或SMP的情况下才真正需要,在单CPU且不可抢占式的内核下,自旋锁的操作为空操作。自旋锁适用于锁使用者保持锁时间比较短的情况下。

互斥锁

互斥锁属于sleep-waiting类型的锁。例如在一个双核的机器上有两个线程(线程A和线程B),它们分别运行在Core0和 Core1上。假设线程A想要通过pthread_mutex_lock操作去得到一个临界区的锁,而此时这个锁正被线程B所持有,那么线程A就会被阻塞 (blocking),Core0 会在此时进行上下文切换(Context Switch)将线程A置于等待队列中,此时Core0就可以运行其他的任务(例如另一个线程C)而不必进行忙等待。而自旋锁则不然,它属于busy-waiting类型的锁,如果线程A是使用pthread_spin_lock操作去请求锁,那么线程A就会一直在 Core0上进行忙等待并不停的进行锁请求,直到得到这个锁为止。

两种锁的加锁原理

互斥锁:线程会从sleep(加锁)——>running(解锁),过程中有上下文的切换,cpu的抢占,信号的发送等开销。

自旋锁:线程一直是running(加锁——>解锁),死循环检测锁的标志位,机制不复杂。

对比互斥锁的起始原始开销要高于自旋锁,但是基本是一劳永逸,临界区持锁时间的大小并不会对互斥锁的开销造成影响,而自旋锁是死循环检测,加锁全程消耗cpu,起始开销虽然低于互斥锁,但是随着持锁时间,加锁的开销是线性增长。

两种锁的应用

互斥锁用于临界区持锁时间比较长的操作,比如下面这些情况都可以考虑

  • 1 临界区有IO操作
  • 2 临界区代码复杂或者循环量大
  • 3 临界区竞争非常激烈
  • 4 单核处理器

至于自旋锁就主要用在临界区持锁时间非常短且CPU资源不紧张的情况下,自旋锁一般用于多核的服务器。

13种锁

1、OSSpinLock

OSSpinLock叫做”自旋锁”,使用时需要导入头文件#import <libkern/OSAtomic.h>

//初始化
OSSpinLock lock = OS_SPINLOCK_INIT;
//加锁
OSSpinLockLock(&lock);
//解锁
OSSpinLockUnlock(&lock);
复制代码

demo

#import "OSSpinLockDemo.h"
#import <libkern/OSAtomic.h>
@interface OSSpinLockDemo()
@property (assign, nonatomic) OSSpinLock ticketLock;
@end

@implementation OSSpinLockDemo

- (instancetype)init
{
self = [super init];
if (self) {
self.ticketLock = OS_SPINLOCK_INIT;
}
return self;
}


//卖票
- (void)sellingTickets{
OSSpinLockLock(&_ticketLock);

[super sellingTickets];

OSSpinLockUnlock(&_ticketLock);
}

@end

复制代码
iOS多线程安全-13种线程锁????

OSSpinLock 在iOS10.0以后就被弃用了,可以使用 os_unfair_lock_lock 替代。而且还有一些安全性问题,具体参考 不再安全的 OSSpinLock

2、os_unfair_lock

os_unfair_lock 用于取代不安全的 OSSpinLock ,从iOS10开始才支持 从底层调用看,等待os_unfair_lock锁的线程会处于休眠状态,并非忙等 需要导入头文件 #import <os/lock.h>

//初始化
os_unfair_lock lock = OS_UNFAIR_LOCK_INIT;
//加锁
os_unfair_lock_lock(&lock);
//解锁
os_unfair_lock_unlock(&lock);
复制代码

demo

#import "os_unfair_lockDemo.h"
#import <os/lock.h>
@interface os_unfair_lockDemo()
@property (assign, nonatomic) os_unfair_lock ticketLock;
@end

@implementation os_unfair_lockDemo
- (instancetype)init
{
self = [super init];
if (self) {
self.ticketLock = OS_UNFAIR_LOCK_INIT;
}
return self;
}


//卖票
- (void)sellingTickets{
os_unfair_lock_lock(&_ticketLock);

[super sellingTickets];

os_unfair_lock_unlock(&_ticketLock);
}
@end
复制代码

3、pthread_mutex

mutex叫做”互斥锁”,等待锁的线程会处于休眠状态。需要导入头文件#import <pthread.h> 使用步骤

  • 1、初始化锁的属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);

/*
* 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

复制代码
  • 2、初始化锁
// 初始化锁
pthread_mutex_init(mutex, &attr);
复制代码
  • 3、初始化锁结束以后,销毁属性
// 销毁属性
pthread_mutexattr_destroy(&attr);
复制代码
  • 4、加锁解锁
pthread_mutex_lock(&_mutex);
pthread_mutex_unlock(&_mutex);
复制代码
  • 5、销毁锁
pthread_mutex_destroy(&_mutex);
复制代码

备注:我们可以不初始化属性,在传属性的时候直接传 NULL ,表示使用默认属性 PTHREAD_MUTEX_NORMALpthread_mutex_init(mutex, NULL);

具体代码

#import "pthread_mutexDemo.h"
#import <pthread.h>
@interface pthread_mutexDemo()
@property (assign, nonatomic) pthread_mutex_t ticketMutex;
@end

@implementation pthread_mutexDemo

- (instancetype)init
{
self = [super init];
if (self) {
// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);
// 初始化锁
pthread_mutex_init(&(_ticketMutex), &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);


}
return self;
}

//卖票
- (void)sellingTickets{
pthread_mutex_lock(&_ticketMutex);

[super sellingTickets];

pthread_mutex_unlock(&_ticketMutex);
}
@end
复制代码

死锁我们稍微的修改一下代码

//卖票
- (void)sellingTickets{
pthread_mutex_lock(&_ticketMutex);
[super sellingTickets];
[self sellingTickets2];
pthread_mutex_unlock(&_ticketMutex);
}


- (void)sellingTickets2{
pthread_mutex_lock(&_ticketMutex);
NSLog(@"%s",__func__);
pthread_mutex_unlock(&_ticketMutex);
}
复制代码
iOS多线程安全-13种线程锁????

上面的代码就会造成 线程死锁 ,因为方法 sellingTickets 的结束需要 sellingTickets2 解锁,方法 sellingTickets2 的结束需要 sellingTickets 解锁,相互引用造成死锁

但是 pthread_mutex_t 里面有一个属性可以解决这个问题 PTHREAD_MUTEX_RECURSIVE

PTHREAD_MUTEX_RECURSIVE 递归锁:允许同一个线程对同一把锁进行重复加锁。要考重点 同一个线程同一把锁

- (instancetype)init
{
self = [super init];
if (self) {
// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
// 初始化锁
pthread_mutex_init(&(_ticketMutex), &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);


}
return self;
}
复制代码

对于上面的问题还有一个解决方案就是在方法 sellingTickets2 中重新在创建一把新的锁,两个方法的锁对象不同,就不会造成线程死锁了。

iOS多线程安全-13种线程锁????

条件

// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
// 初始化锁
pthread_mutex_init(&_mutex, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);

// 初始化条件
pthread_cond_t condition
pthread_cond_init(&_cond, NULL);

// 等待条件
pthread_cond_wait(&_cond, &_mutex);

//激活一个等待该条件的线程
pthread_cond_signal(&_cond);
//激活所有等待该条件的线程
pthread_cond_broadcast(&_cond);

//销毁资源
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
复制代码

使用案例:假设我们有一个数组,里面有两个线程,一个是添加数组,一个是删除数组,我们先调用删除数组,在调用添加数组,但是在数组为空的时候不调用删除数组。

#import "pthread_mutexDemo1.h"
#import <pthread.h>

@interface pthread_mutexDemo1()
@property (assign, nonatomic) pthread_mutex_t mutex;
@property (assign, nonatomic) pthread_cond_t cond;
@property (strong, nonatomic) NSMutableArray *data;
@end

@implementation pthread_mutexDemo1

- (instancetype)init
{
if (self = [super init]) {
// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
// 初始化锁
pthread_mutex_init(&_mutex, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);

// 初始化条件
pthread_cond_init(&_cond, NULL);

self.data = [NSMutableArray array];
}
return self;
}
- (void)otherTest
{
[[[NSThread alloc] initWithTarget:self selector:@selector(__remove) object:nil] start];

[[[NSThread alloc] initWithTarget:self selector:@selector(__add) object:nil] start];
}

// 线程1
// 删除数组中的元素
- (void)__remove
{
pthread_mutex_lock(&_mutex);
NSLog(@"__remove - begin");

if (self.data.count == 0) {
// 等待
pthread_cond_wait(&_cond, &_mutex);
}

[self.data removeLastObject];
NSLog(@"删除了元素");

pthread_mutex_unlock(&_mutex);
}

// 线程2
// 往数组中添加元素
- (void)__add
{
pthread_mutex_lock(&_mutex);

sleep(1);

[self.data addObject:@"Test"];
NSLog(@"添加了元素");

// 激活一个等待该条件的线程
pthread_cond_signal(&_cond);

pthread_mutex_unlock(&_mutex);
}

- (void)dealloc
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
复制代码

为了准确测试我们可以在 __addsleep(1)

iOS多线程安全-13种线程锁????

4、NSLock

NSLock是对 mutex 普通锁的封装。 pthread_mutex_init(mutex, NULL);

NSLock 遵循 NSLocking 协议。Lock 方法是加锁,unlock 是解锁,tryLock 是尝试加锁,如果失败的话返回 NO,lockBeforeDate: 是在指定Date之前尝试加锁,如果在指定时间之前都不能加锁,则返回NO

@protocol NSLocking
- (void)lock;
- (void)unlock;
@end

@interface NSLock : NSObject <NSLocking> {
@private
void *_priv;
}

- (BOOL)tryLock;
- (BOOL)lockBeforeDate:(NSDate *)limit;
@property (nullable, copy) NSString *name
@end
复制代码

使用起来也是十分的简单

#import "LockDemo.h"
@interface LockDemo()
@property (strong, nonatomic) NSLock *ticketLock;
@end
@implementation LockDemo
//卖票
- (void)sellingTickets{
[self.ticketLock lock];
[super sellingTickets];
[self.ticketLock unlock];
}

@end
复制代码

5、NSRecursiveLock

NSRecursiveLock是对 mutex 递归锁的封装,API跟NSLock基本一致

#import "RecursiveLockDemo.h"
@interface RecursiveLockDemo()
@property (nonatomic,strong) NSRecursiveLock *ticketLock;
@end
@implementation RecursiveLockDemo
//卖票
- (void)sellingTickets{
[self.ticketLock lock];
[super sellingTickets];
[self.ticketLock unlock];
}
@end
复制代码

6、NSCondition

NSCondition是对 mutexcond 的封装,更加面向对象,我们使用起来也更加的方便简洁

@interface NSCondition : NSObject <NSLocking> {
- (void)wait;
- (BOOL)waitUntilDate:(NSDate *)limit;
- (void)signal;
- (void)broadcast;
@property (nullable, copy) NSString *name 
@end
复制代码

对于上面那个数组操作的案例我们就可以变成这个样子了

// 线程1
// 删除数组中的元素
- (void)__remove
{
[self.condition lock];
if (self.data.count == 0) {
// 等待
[self.condition wait];
}
[self.data removeLastObject];
NSLog(@"删除了元素");
[self.condition unlock];
}

// 线程2
// 往数组中添加元素
- (void)__add
{
[self.condition lock];
sleep(1);
[self.data addObject:@"Test"];
NSLog(@"添加了元素");
// 信号
[self.condition signal];
[self.condition unlock];
}
复制代码

7、NSConditionLock

NSConditionLock是对NSCondition的进一步封装,可以设置具体的条件值

@interface NSConditionLock : NSObject <NSLocking> {
 
- (instancetype)initWithCondition:(NSInteger)condition;

@property (readonly) NSInteger condition;
- (void)lockWhenCondition:(NSInteger)condition;
- (BOOL)tryLock;
- (BOOL)tryLockWhenCondition:(NSInteger)condition;
- (void)unlockWithCondition:(NSInteger)condition;
- (BOOL)lockBeforeDate:(NSDate *)limit;
- (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit;
@property (nullable, copy) NSString *name;
@end
复制代码

里面有三个常用的方法

  • 1、 initWithCondition: 初始化 Condition ,并且设置状态值
  • 2、 lockWhenCondition:(NSInteger)condition: 当状态值为condition的时候加锁
  • 3、 unlockWithCondition:(NSInteger)condition 当状态值为condition的时候解锁
@interface NSConditionLockDemo()
@property (strong, nonatomic) NSConditionLock *conditionLock;
@end
@implementation NSConditionLockDemo
- (instancetype)init
{
if (self = [super init]) {
self.conditionLock = [[NSConditionLock alloc] initWithCondition:1];
}
return self;
}

- (void)otherTest
{
[[[NSThread alloc] initWithTarget:self selector:@selector(__one) object:nil] start];
[[[NSThread alloc] initWithTarget:self selector:@selector(__two) object:nil] start];
}

- (void)__one
{
[self.conditionLock lock];
NSLog(@"__one");
sleep(1);
[self.conditionLock unlockWithCondition:2];
}

- (void)__two
{
[self.conditionLock lockWhenCondition:2];
NSLog(@"__two");
[self.conditionLock unlockWithCondition:3];
}
@end
复制代码
iOS多线程安全-13种线程锁????

8、dispatch_semaphore

  • semaphore叫做”信号量”
  • 信号量的初始值,可以用来控制线程并发访问的最大数量
  • 信号量的初始值为1,代表同时只允许1条线程访问资源,保证线程同步
//表示最多开启5个线程
dispatch_semaphore_create(5);
// 如果信号量的值 > 0,就让信号量的值减1,然后继续往下执行代码
// 如果信号量的值 <= 0,就会休眠等待,直到信号量的值变成>0,就让信号量的值减1,然后继续往下执行代码
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
// 让信号量的值+1
dispatch_semaphore_signal(self.semaphore);
复制代码
@interface dispatch_semaphoreDemo()
@property (strong, nonatomic) dispatch_semaphore_t semaphore;
@end
@implementation dispatch_semaphoreDemo
- (instancetype)init
{
if (self = [super init]) {
self.semaphore = dispatch_semaphore_create(1);
}
return self;
}
- (void)otherTest
{
for (int i = 0; i < 20; i++) {
[[[NSThread alloc] initWithTarget:self selector:@selector(test) object:nil] start];
}
}
- (void)test
{
// 如果信号量的值 > 0,就让信号量的值减1,然后继续往下执行代码
// 如果信号量的值 <= 0,就会休眠等待,直到信号量的值变成>0,就让信号量的值减1,然后继续往下执行代码
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);

sleep(2);
NSLog(@"test - %@", [NSThread currentThread]);

// 让信号量的值+1
dispatch_semaphore_signal(self.semaphore);
}
@end
复制代码

我们在运行代码打印的时候发现,每隔一秒出现一次打印。虽然我们同时开启20个线程,但是一次只能访问一条线程的资源

9、dispatch_queue

使用GCD的串行队列也可以实现线程同步的

dispatch_queue_t queue = dispatch_queue_create("test", DISPATCH_QUEUE_SERIAL);
dispatch_sync(queue, ^{
// 追加任务1
for (int i = 0; i < 2; ++i) {
NSLog(@"1---%@",[NSThread currentThread]);
}
});

dispatch_sync(queue, ^{
// 追加任务2
for (int i = 0; i < 2; ++i) {
NSLog(@"2---%@",[NSThread currentThread]);
}
});
复制代码

10、@synchronized

@synchronized 是对 mutex 递归锁的封装, @synchronized(obj) 内部会生成obj对应的递归锁,然后进行加锁、解锁操作

//卖票
- (void)sellingTickets{
@synchronized ([self class]) {
[super sellingTickets];
}
}
复制代码

对是实现底层我们可以在 objc4的objc-sync.mm文件中找到 synchronized 就是在开始和结束的时候调用了 objc_sync_enter & objc_sync_exit 方法。

objc_sync_enter 实现

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;
}
复制代码

就是根据 id2data 方法找到一个 data 对象,然后在对 data 对象进行 mutex.lock() 加锁操作。我们点击进入 id2data 方法继续查找

#define LIST_FOR_OBJ(obj) sDataLists[obj].data
static StripedMap<SyncList> sDataLists;
复制代码

发现获取 data 对象的方法其实就是根据 sDataLists[obj].data 这个方法来实现的,也就是一个哈希表。

关于 @synchronized,这儿比你想知道的还要多

11、atomic

  • atomic用于保证属性setter、getter的原子性操作,相当于在getter和setter内部加了线程同步的锁
  • 可以参考源码objc4的objc-accessors.mm
  • 它并不能保证使用属性的过程是线程安全的

12、pthread_rwlock:读写锁

pthread_rwlock经常用于文件等数据的读写操作,需要导入头文件 #import <pthread.h>

iOS中的读写安全方案需要注意一下场景

  • 1、同一时间,只能有1个线程进行写的操作
  • 2、同一时间,允许有多个线程进行读的操作
  • 3、同一时间,不允许既有写的操作,又有读的操作
//初始化锁
pthread_rwlock_t lock;
pthread_rwlock_init(&_lock, NULL);

//读加锁
pthread_rwlock_rdlock(&_lock);
//读尝试加锁
pthread_rwlock_trywrlock(&_lock)

//写加锁
pthread_rwlock_wrlock(&_lock);
//写尝试加锁
pthread_rwlock_trywrlock(&_lock)

//解锁
pthread_rwlock_unlock(&_lock);
//销毁
pthread_rwlock_destroy(&_lock);

复制代码
#import <pthread.h>
@interface pthread_rwlockDemo ()
@property (assign, nonatomic) pthread_rwlock_t lock;
@end

@implementation pthread_rwlockDemo

- (instancetype)init
{
self = [super init];
if (self) {
// 初始化锁
pthread_rwlock_init(&_lock, NULL);
}
return self;
}

- (void)otherTest{
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);

for (int i = 0; i < 10; i++) {
dispatch_async(queue, ^{
[self read];
});
dispatch_async(queue, ^{
[self write];
});
}
}
- (void)read {
pthread_rwlock_rdlock(&_lock);
sleep(1);
NSLog(@"%s", __func__);
pthread_rwlock_unlock(&_lock);
}
- (void)write
{
pthread_rwlock_wrlock(&_lock);
sleep(1);
NSLog(@"%s", __func__);
pthread_rwlock_unlock(&_lock);
}
- (void)dealloc
{
pthread_rwlock_destroy(&_lock);
}
@end
复制代码
iOS多线程安全-13种线程锁????

我们可以发现读操作1s有可能出现多次,但是写操作不会

13、dispatch_barrier_async

这个函数传入的并发队列必须是自己通过dispatch_queue_cretate创建的 如果传入的是一个串行或是一个全局的并发队列,那这个函数便等同于dispatch_async函数的效果

//初始化
self.queue = dispatch_queue_create("rw_queue", DISPATCH_QUEUE_CONCURRENT);
//读操作
dispatch_async(self.queue, ^{
});
//写操作
dispatch_barrier_async(self.queue, ^{
 
});
复制代码

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Agile Web Development with Rails, Third Edition

Agile Web Development with Rails, Third Edition

Sam Ruby、Dave Thomas、David Heinemeier Hansson / Pragmatic Bookshelf / 2009-03-17 / USD 43.95

Rails just keeps on changing. Rails 2, released in 2008, brings hundreds of improvements, including new support for RESTful applications, new generator options, and so on. And, as importantly, we’ve a......一起来看看 《Agile Web Development with Rails, Third Edition》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换