C#并行编程(6):线程同步面面观
理解线程同步
线程的数据访问
在并行(多线程)环境中,不可避免地会存在多个线程同时访问某个数据的情况。多个线程对共享数据的访问有下面3种情形:
- 多个线程同时读取数据;
- 单个线程更新数据,此时其他线程读取数据;
- 多个线程同时更新数据。
显而易见,多个线程同时读取数据是不会产生任何问题的。仅有一个线程更新数据的时候,貌似也没有问题,但真的没有问题吗?多个线程同时更新数据,很明显,你可能把我的更改覆盖掉了,数据从此不再可信。
什么是线程同步
为了解决多线程同时访问共享数据可能导致数据被破坏的问题,我们需要采取一些措施来保证数据的一致性,让每个线程都能准确地读取或更新数据。
问题的根源在于多个线程同时访问数据,那么只要我们保证同一时间只有一个线程访问数据,就能解决问题。保证同一时间只有一个线程访问数据的处理,就是线程同步了。我在访问数据的时候,你们都先等着,我完事了你们再来。
C#中的线程同步
.NET提供了很多线程同步的方式,这些方式分为用户模式和内核模式以及混合模式(即用户模式与内核模式的结合),下面会总结C#/.NET中各模式下的线程同步。
用户模式与内核模式
Windows操作系统下,CPU跟据所执行代码的不同,会在两种模式下进行切换。CPU执行应用程序代码(如我们开发的.NET程序)时,一般运行在用户模式下;执行操作系统核心代码(内核函数或者某些设备驱动程序)时,CPU则切换到内核模式。
用户模式的代码只能访问自身进程的专有地址空间,代码异常不会影响到其他程序或者操作系统;内核模式的所有代码共享单个地址空间,代码异常将可能导致系统崩溃。CPU的模式切换,是为了保证应用程序和操作系统的稳定性。
应用程序中,线程可以通过Windows API调用操作系统内核函数,这时候执行线程的CPU将从用户模式切换到内核模式,执行完操作系统函数后,再由内核模式切换到用户模式。CPU的模式切换是很耗时的,据《Windows核心编程》中的描述,CPU模式的切换,要占用1000个以上的CPU周期。因此,在我们的.NET程序中,应该尽可能地避免CPU的模式切换。
用户模式线程同步
用户模式下,利用特殊的CPU指令来协调线程,使同一时间只有一个线程能访问某内存地址,这种协调在硬件中发生,速度很快。这种模式下,CPU指令对线程的阻塞很短暂,操作系统调度线程时不会认为该线程已被阻塞,这种情况下,线程池不会创建新的线程来替换该线程。
用户模式下,等待资源的线程会一直被操作系统调度,导致线程的“自旋”并因此浪费很多的CPU资源。如果某线程一直占着资源不释放,等待该资源的线程将一直处于自旋状态,这样就造成了“活锁”,活锁除了浪费内存外,还会浪费大量CPU。
.NET提供两种用户模式的线程同步,volatile
和interlocked
,即易变和互锁。
volatile
关键字和Volatile
上面我们遗留了一个问题:只有一个线程更新数据,其他线程读取数据,会不会出现问题?先看一个例子:
private static bool _stop;
public static void Run()
{//主线程
Task.Run(() =>
{//任务线程
int number = 1;
while (!_stop) //读取_stop
{
number++;
}
Console.WriteLine($"increase stopped,value = {number}");
});
Thread.Sleep(1000);
_stop = true; //更新_stop
}
编译器和CPU会对上面的代码进行优化(调试模式不会优化),任务线程在执行时,会把_stop读取到CPU寄存器中,while循环的时候,每次都从当前CPU寄存器中读取_stop;同样,主线程执行的时候CPU也会把_stop读取到寄存器,更新_stop时,先更新是CPU寄存器中的_stop值,再把值存到变量_stop;在并行环境中,主线程和任务线程独立执行,主线程对_stop的更新并不会公开到任务线程,这样,任务线程的while循环便不会停止,永远无法得到输出。
把变量读到寄存器只是CPU优化代码的一种方式,CPU还可能调整代码的执行顺序,当前,CPU任务这种调整不会改变代码的意图。上面的代码说明,由于编译器和CPU的优化,只有一个线程更新数据,也可能存在问题。
这种情况,我们可以使用volatile
关键字或者类System.Threading.Volatile
来阻止编译器和CPU的优化,这种阻止利用的是内存屏障MemoryBarrier
,它告诉CPU在执行完屏障之前的内存存取后才能执行屏障后面的内存存取。上面代码的问题在于,while循环读取到的值总是CPU寄存器中的false。我们把while循环的条件改成!Volatile.Read(ref _stop)
或者把用volatile
声明变量_stop,while条件直接读取内存中的值,问题就能得到解决。
Interlocked
原子访问
.NET提供的另一种用户模式线程同步方式是System.Threading.Interlocked
。Interlocked
的工作依赖于代码运行的CPU平台,如果是X86的CPU,Interlocked函数会在总线上维持一个硬件信号,来阻止其他CPU访问同一内存地址(《Windows核心编程第五版》)。计算机对变量的修改一般来说并不是原子性的,而是分为3个步骤:
- 将变量值加载到CPU寄存器
- 改变值
- 将更新后的值存储到内存中
假如执行了前两个步骤后,CPU被抢占,变量在之前线程中的修改将丢失。Interlocked函数保证对值的修改是原子性的,一个线程完成变量的修改和存储后,另一个线程才能修改变量。
System.Threading.Interlocked
提供了很多方法,例如递增、递减、求和等,下面用Interlocked的递增方法展示其线程同步功能。
public static void Run()
{
DoIncrease(100000);
}
private static void DoIncrease(int incrementPerThread)
{
int number1 = 0;
int number2 = 0;
Console.WriteLine($"use two threads to increase zero. each thread increase {incrementPerThread}.");
IList<Task> increaseTasks = new List<Task>();
increaseTasks.Add(Task.Run(() =>
{
Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} increasing number1.");
for (int i = 0; i < incrementPerThread; i++)
{
Interlocked.Increment(ref number1);
}
}));
increaseTasks.Add(Task.Run(() =>
{
Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} increasing number1.");
for (int i = 0; i < incrementPerThread; i++)
{
Interlocked.Increment(ref number1);
}
}));
increaseTasks.Add(Task.Run(() =>
{
Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} increasing number2.");
for (int i = 0; i < incrementPerThread; i++)
{
number2++;
}
}));
increaseTasks.Add(Task.Run(() =>
{
Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} increasing number2.");
for (int i = 0; i < incrementPerThread; i++)
{
number2++;
}
}));
Task.WaitAll(increaseTasks.ToArray());
Console.WriteLine($"use interlocked: number1 result = {number1}");
Console.WriteLine($"normal increase: number2 result = {number2}");
}
运行上面的代码多次(每个线程增加的数量尽量大,否则不容易体现结果),每次number1的结果都一样,number2的结果都不同,足以体现Interlocked的线程同步功能。
SpinLock
自旋锁
System.Threading.SpinLock
是基于InterLocked和SpinWait实现的轻量级自旋锁,具体的实现方式这里不去关心。SpinLock
的简单用法如下:
private static SpinLock _spinlock = new SpinLock();
public static void DoWork()
{
bool lockTaken = false;
try
{
_spinlock.Enter(ref lockTaken);
//DoWork
}
finally
{
if (lockTaken)
{
_spinlock.Exit(false);
}
}
}
SpinLock
很轻量级,性能较高,但由于是自旋锁,锁定的操作应该是很快完成,否则会因线程自旋而浪费CPU。
内核模式线程同步
除了用户模式的两种线程同步方式,我们还会利用Windows系统的内核对象实现线程的同步。使用系统内核对象将会导致执行线程的CPU运行模式的切换,这会有很大的消耗,所以能够使用用户模式的线程同步就尽量避免使用内核模式。
内核模式下,线程在等待资源时会被系统阻塞,避免了CPU的浪费,这是内核模式优势。假如线程等待的资源一直被占用则线程将一直处于阻塞状态,造成“死锁”。相对于活锁,死锁只会浪费内存资源。
我们使用系统内核中的事件、信号量和互斥量进行内核模式的线程同步。
利用内核事件实现线程同步
事件实际上是由系统内核维护的一个布尔值。
.NET提供System.Threading.EventWaitHandle
进行线程的信号交互。EventWaitHandle
继承WaitHandle
(封装等待对共享资源独占访问的操作系统特定的对象),有三个关键方法:
Set()
:将事件状态设置为终止状态,允许一个或多个等待线程继续。Reset()
:将事件状态设置为非终止状态,导致线程阻塞WaitOne()
:阻塞线程直到收到事件状态信号
线程交互事件有自动重置和手动重置两种类型,分别由AutoResetEvent
和ManualResetEvent
继承EventWaitHandle
得到。自动重置事件在Set唤醒第一个阻塞线程之后,会自动Reset事件,其他阻塞线程仍保持阻塞状态;而手动重置事件Set时,会唤醒所有被该事件阻塞的线程,手动Reset后,事件才会继续起作用。手动重置事件的这种性质,导致它不能用于线程同步,因为不能保证同一时间只有一个线程访问资源;相反,自动重置时间则很适合用来处理线程同步。
下面的例子演示了利用自动重置时间进行的线程同步。
public static void Run()
{
DoIncrease(100000);
}
private static void DoIncrease(int incrementPerThread)
{
int number = 0;
Console.WriteLine($"use two threads to increase zero. each thread increase {incrementPerThread}.");
AutoResetEvent are = new AutoResetEvent(true);//初始化一个终止状态的线程同步事件
IList<Task> increaseTasks = new List<Task>();
increaseTasks.Add(Task.Run(() =>
{
Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} is increasing the number.");
for (int i = 0; i < incrementPerThread; i++)
{
are.WaitOne();// 阻塞线程,直到被同步事件唤醒
number++;
are.Set();// 将事件设为终止状态,唤醒其他线程
}
}));
increaseTasks.Add(Task.Run(() =>
{
Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} is increasing the number.");
for (int i = 0; i < incrementPerThread; i++)
{
are.WaitOne();
number++;
are.Set();
}
}));
Task.WaitAll(increaseTasks.ToArray());
are.Dispose();
Console.WriteLine($"use AutoResetEvent: result = {number}");
}
利用信号量进行线程同步
信号量是系统内核维护的一个整型变量。
信号量值为0时,所有等待信号量的线程会被阻塞;信号量值大于零0,等待的线程会被解除阻塞,每唤醒一个阻塞的线程,系统内核就会把信号量的值减1。此外,我们能够对信号量进行最大值限制,从而控制访问同一资源的最大线程数量。
.Net中,利用System.Threading.Semaphore
进行信号量操作。下面时利用信号量实现线程同步的一个例子。
public static void Run()
{
DoIncrease(100000);
}
private static void DoIncrease(int incrementPerThread)
{
int number = 0;
Console.WriteLine($"use two threads to increase zero. each thread increase {incrementPerThread}.");
Semaphore semaphore = new Semaphore(1,1); //初始化信号量,这里初始值要设置为1,否则同步会有问题
IList<Task> increaseTasks = new List<Task>();
increaseTasks.Add(Task.Run(() =>
{
Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} is increasing the number.");
for (int i = 0; i < incrementPerThread; i++)
{
semaphore.WaitOne();
number++;
semaphore.Release(1);// 退出信号量
}
}));
increaseTasks.Add(Task.Run(() =>
{
Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} is increasing the number.");
for (int i = 0; i < incrementPerThread; i++)
{
semaphore.WaitOne();
number++;
semaphore.Release(1);
}
}));
Task.WaitAll(increaseTasks.ToArray());
semaphore.Dispose();
Console.WriteLine($"use Semaphore: result = {number}");
}
利用互斥体进程线程同步
互斥体Mutex的使用与自动重置事件和信号量类似,这里不再进行详细的总结。
互斥体常被用来保证应用程序只有一个实例运行,具体用法如下:
bool createNew;
using (new Mutex(true, Assembly.GetExecutingAssembly().FullName, out createNew))
{
if (!createNew)
{//系统已经存在同名的互斥体,说明已有程序实例在运行
//这里做一些提示
Environment.Exit(0);//退出
}
else
{
//启动实例的代码
}
}
线程同步的混合模式
通过上面的总结我们知道,用户模式和内核模式由各自的优缺点,需要有一种模式既能兼顾用户和内核模式的优点又能避免他们的缺点,这就是混合模式。
混合模式会优先使用用户模式的线程同步处理,当多个线程竞争同步锁的时候,才会使用内核对象进行处理。如果多个线程一直不产生资源竞争,就不会发生CPU用户模式到内核模式的转换,开始资源竞争时,又会通过线程阻塞来防止CPU资源的浪费。
.NET中提供了多种混合模式的线程同步方式。例如手工重置事件和信号量的简化版本ManualResetEventSlim
及SemaphoreSlim
,他们是线程在用户模式中自旋,直到发生资源竞争。具体使用与各自的内核模式一样,这里不再赘述。
lock关键字和Monitor
相信lock加锁是很多人做常用的线程同步方式。lock的使用很简单,如下:
private static readonly object _syncObject = new object();
public static void DoWork()
{
lock (_syncObject)
{
//DoWork
}
}
实际上,lock语法是对System.Threading.Monitor
使用的一种简化,Monitor
的用法如下:
private static readonly object _syncObject = new object();
public static void DoWork()
{
Monitor.Enter(_syncObject);
//DoWork
Monitor.Exit(_syncObject);
}
使用Monitor
的可能会出先一些意象不到的问题。例如,如果不相关的业务代码在使用Monitor进行线程同步的时候,锁定了同一字符串,将会造成不相关业务代码的同步执行;此外需要注意的是,Monitor不能使用值类型作为锁对象,值类型会被装箱,装箱后的对象不同,将导致无法同步。
读写锁ReaderWriterLockSlim
ReaderWriterLockSlim
可以用来实现多线程读取或独占写入的资源访问。读写锁的线程控制逻辑如下:
- 一个线程写数据时,其他请求资源的线程全部被阻塞;
- 一个线程读数据时,写线程被阻塞,其他读线程能继续运行;
- 写结束时,解除其他某个写线程的阻塞,或者解除所有读线程的阻塞;
- 读结束时,解除一个写线程的阻塞。
下面是读写锁的简单用法,详细用法可参考msdn文档。
private static readonly ReaderWriterLockSlim _rwlock = new ReaderWriterLockSlim();
public static void DoWork()
{
_rwlock.EnterWriteLock();
//DoWork
_rwlock.ExitWriteLock();
}
ReaderWriterLockSlim
还有一个比较老的版本ReaderWriterLock
,据说存在较多问题应尽量避免使用。
线程安全集合
.NET除了提供包含上面总结到的各种线程同步的诸多方式外,还封装了一些线程安全集合。这些集合在内部实现了线程同步,我们直接使用即可,很友好。线程安全集合在命名空间System.Collections.Concurrent
下,包括ConcurrentQueue (T)
, ConcurrentStack<T>
,ConcurrentDictionary<TKey,TValue>
,ConcurrentBag<T>
,BlockingCollection<T>
,具体可阅读《何时使用线程安全集合》。
各种线程同步性能对比
下面我们对整数零进行多线程递增操作,每个线程固定递增量,来测试以下各种同步方式的性能对比。测试代码如下。
/// <summary>
/// 全局目标数据,使用多线程进行递增
/// </summary>
private static int _numberToIncrease;
public static void Run()
{
int increment = 100000;
int threadCount = 4;
DoIncrease(increment, threadCount, DoIncreaseByInterLocked);
DoIncrease(increment, threadCount, DoIncreaseWithSpinLock);
DoIncrease(increment, threadCount, DoIncreaseWithEvent);
DoIncrease(increment, threadCount, DoIncreaseWithSemaphore);
DoIncrease(increment, threadCount, DoIncreaseWithMonitor);
DoIncrease(increment, threadCount, DoIncreaseWithReaderWriterLockSlim);
}
/// <summary>
/// 递增运算
/// </summary>
/// <param name="increment">单线程递增量</param>
/// <param name="threadCount">线程数</param>
/// <param name="action">递增方法</param>
public static void DoIncrease(int increment, int threadCount, Action<int> action)
{
_numberToIncrease = 0; //重置目标数据
IList<Task> increaseTasks = new List<Task>(threadCount);
Stopwatch watch = Stopwatch.StartNew();
for (int i = 0; i < threadCount; i++)
{
increaseTasks.Add(Task.Run(() => action(increment)));
}
Task.WaitAll(increaseTasks.ToArray());
Console.WriteLine($"{action.Method.Name}=> Result: {_numberToIncrease} , Time: {watch.ElapsedMilliseconds} ms.");
}
#region 使用Interlocked,用户模式
public static void DoIncreaseByInterLocked(int increment)
{
for (int i = 0; i < increment; i++)
{
Interlocked.Increment(ref _numberToIncrease);
}
}
#endregion
#region 使用SpinLock,用户模式
private static SpinLock _spinlock = new SpinLock();
public static void DoIncreaseWithSpinLock(int increment)
{
for (int i = 0; i < increment; i++)
{
bool lockTaken = false;
try
{
_spinlock.Enter(ref lockTaken);
_numberToIncrease++;
}
finally
{
if (lockTaken)
{
_spinlock.Exit(false);
}
}
}
}
#endregion
#region 使用信号量Semaphore,内核模式
private static readonly Semaphore _semaphore = new Semaphore(1, 10);
public static void DoIncreaseWithSemaphore(int increment)
{
for (int i = 0; i < increment; i++)
{
_semaphore.WaitOne();
_numberToIncrease++;
_semaphore.Release(1);
}
}
#endregion
#region 使用事件AutoResetEvent,内核模式
private static readonly AutoResetEvent _are = new AutoResetEvent(true);
public static void DoIncreaseWithEvent(int increment)
{
for (int i = 0; i < increment; i++)
{
_are.WaitOne();
_numberToIncrease++;
_are.Set();
}
}
#endregion
#region 使用Monitor,混合模式
private static readonly object _monitorLocker = new object();
public static void DoIncreaseWithMonitor(int increment)
{
for (int i = 0; i < increment; i++)
{
bool lockTaken = false;
try
{
Monitor.Enter(_monitorLocker, ref lockTaken);
_numberToIncrease++;
}
finally
{
if (lockTaken)
{
Monitor.Exit(_monitorLocker);
}
}
}
}
#endregion
#region 使用ReaderWriterLockSlim,混合模式
private static readonly ReaderWriterLockSlim _rwlock = new ReaderWriterLockSlim();
public static void DoIncreaseWithReaderWriterLockSlim(int increment)
{
for (int i = 0; i < increment; i++)
{
_rwlock.EnterWriteLock();
_numberToIncrease++;
_rwlock.ExitWriteLock();
}
}
#endregion
下面是一组测试结果,可以很明显地看出,内核模式是相当耗时的,应尽量避免使用。而用户模式和混合模式,也需要根据具体的场景进行选择。这个测试过于简单,不具有普遍性。
DoIncreaseByInterLocked=> Result: 400000 , Time: 15 ms.
DoIncreaseWithSpinLock=> Result: 400000 , Time: 75 ms.
DoIncreaseWithEvent=> Result: 400000 , Time: 1892 ms.
DoIncreaseWithSemaphore=> Result: 400000 , Time: 1779 ms.
DoIncreaseWithMonitor=> Result: 400000 , Time: 14 ms.
DoIncreaseWithReaderWriterLockSlim=> Result: 400000 , Time: 22 ms.
小结
本文对C#/.NET中的线程同步进行了尽量详尽的总结,并行环境中在追求程序的高性能、响应性的同时,务必要保证数据的安全性。
C#并行编程系列的文章暂时就告一段落了。刚开始写博客,文章肯定存在不少问题,欢迎各位博友指出。
C#并行编程(6):线程同步面面观的更多相关文章
- .NET面试题解析(07)-多线程编程与线程同步
系列文章目录地址: .NET面试题解析(00)-开篇来谈谈面试 & 系列文章索引 关于线程的知识点其实是很多的,比如多线程编程.线程上下文.异步编程.线程同步构造.GUI的跨线程访问等等, ...
- .NET面试题解析(07)-多线程编程与线程同步 (转)
http://www.cnblogs.com/anding/p/5301754.html 系列文章目录地址: .NET面试题解析(00)-开篇来谈谈面试 & 系列文章索引 关于线程的知识点其实 ...
- Windows编程之线程同步
本笔记整理自:<Windows核心编程(第五版)> 目录 什么是线程同步 用户方式中的线程同步 原子访问:Interlocked系列函数 CRITICAL_SECTION:关键段 内核对象 ...
- Win32多线程编程(3) — 线程同步与通信
一.线程间数据通信 系统从进程的地址空间中分配内存给线程栈使用.新线程与创建它的线程在相同的进程上下文中运行.因此,新线程可以访问进程内核对象的所有句柄.进程中的所有内存以及同一个进程中其他所有线 ...
- Java多线程编程(4)--线程同步机制
一.锁 1.锁的概念 线程安全问题的产生是因为多个线程并发访问共享数据造成的,如果能将多个线程对共享数据的并发访问改为串行访问,即一个共享数据同一时刻只能被一个线程访问,就可以避免线程安全问题.锁 ...
- java并发编程基础——线程同步
线程同步 一.线程安全问题 如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码.如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安 ...
- iOS多线程编程:线程同步总结
1:原子操作 - OSAtomic系列函数 iOS平台下的原子操作函数都以OSAtomic开头,使用时需要包含头文件<libkern/OSBase.h>.不同线程如果通过原子操作函数对同一 ...
- C# 多线程编程第二步——线程同步与线程安全
上一篇博客学习了如何简单的使用多线程.其实普通的多线程确实很简单,但是一个安全的高效的多线程却不那么简单.所以很多时候不正确的使用多线程反倒会影响程序的性能. 下面先看一个例子 : class Pro ...
- linux系统编程:线程同步-相互排斥量(mutex)
线程同步-相互排斥量(mutex) 线程同步 多个线程同一时候訪问共享数据时可能会冲突,于是须要实现线程同步. 一个线程冲突的演示样例 #include <stdio.h> #includ ...
随机推荐
- DataTables合并单元格(rowspan)的实现思路(多分组分类的情况)
直接上代码,原理之前的随笔已经讲过了.http://www.cnblogs.com/hdwang/p/7115835.html 1.先看看效果 2.html代码,含js代码 2.1 common.js ...
- IE盒模型和W3C盒子模型的区别
其实这个问题到现在真的是没有意义了,因为早在IE6的兼容模式开始就已经弃用了IE盒子模型了,但是现在的各种面试题还是会时常出现这样的上世纪的题目,我觉得其实时纯粹的刁难. 好了,吐槽不多说了,直接上图 ...
- Hadoop Yarn源码 - day2
接着昨天的继续看hadoop-yarn-api,昨天看了api package下的4个协议,今天来看下con package下的代码 conf目录下的内容比较少,就4个文件分别是Configurati ...
- bzoj千题计划283:bzoj4516: [Sdoi2016]生成魔咒(后缀数组)
http://www.lydsy.com/JudgeOnline/problem.php?id=4516 考虑在后面新加一个字母产生的影响 假设是第i个 如果不考虑重复,那么会增加i个不同的字符串 考 ...
- hdu 6166 Senior Pan
http://acm.hdu.edu.cn/showproblem.php?pid=6166 题意: 给出一张无向图,给定k个特殊点 求这k个特殊点两两之间的最短路 二进制分组 枚举一位二进制位 这一 ...
- bzoj千题计划188:bzoj1923: [Sdoi2010]外星千足虫 (高斯—若尔当消元法解异或方程组)
http://www.lydsy.com/JudgeOnline/problem.php?id=1923 #include<cstdio> #include<cstring> ...
- PHP 文件加密Zend Guard Loader 学习和使用(如何安装ioncube扩展对PHP代码加密)
一.大体流程图 二.PHP 项目文件加密 下表列出了Zend产品中的PHP版本及其内部API版本和Zend产品版本. 如何加密请往后看 三.如何使用 第一步:确认当前环境 Amai Phalcon 前 ...
- [iOS问题归总]iPhone上传项目遇到的问题
1. 在上传项目的时候,UpLoad App Store后弹出iTunes Store operation failed. 错误原因:你在ItunesConnect(https://itunescon ...
- CSS那些事!这个篇幅是我特意开的,不是因为帮助小菜之类的,而是在多人的团队配合中各种命名冲突的规范让人蛋疼
CSS那些事!这个篇幅是我特意开的,不是因为帮助小菜之类的,而是在多人的团队配合中各种命名冲突的规范让人蛋疼. css这个东西只要不是新的离谱都会写,但是每个人的命名风格,方法,都不同 有人喜欢驼峰, ...
- HDU 4509 湫湫系列故事——减肥记II (简单模拟)
题意:一天一共有1440分钟,主人公每天有n件事要做,给出这n件事开始跟结束的时间,然后让你求出,空闲的时间的总分钟数是多少. 解题报告:简单模拟,只要开个一维数组标记那个每个分钟是否是有事的就可以了 ...